diff --git a/requirements-dev.txt b/requirements-dev.txt index e0cea116cc6299a1bc3fac6d4be62411e6bac7b9..26cb93d1b25ed29e38230bbd1dccc37ff2cdb5db 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ +-r src/InSillyCloWeb/requirements.txt black~=24.10.0 diff --git a/src/InSillyCloWeb/assemblies/admin.py b/src/InSillyCloWeb/assemblies/admin.py index 7c406005c5135ecab37356eeabc9bc51a8fcc24b..35d97a12c4ece73a8b39ab7cf78790e665bdd22f 100644 --- a/src/InSillyCloWeb/assemblies/admin.py +++ b/src/InSillyCloWeb/assemblies/admin.py @@ -16,7 +16,6 @@ class AssemblyAdmin(ViewOnSiteModelAdmin, admin.ModelAdmin): list_display = ( 'name', 'is_public', - 'is_input_part_typed', 'output_separator', 'restriction_enzyme', ) diff --git a/src/InSillyCloWeb/assemblies/forms.py b/src/InSillyCloWeb/assemblies/forms.py index 9b468d296264a5677d809ee805441b4c80e6297c..2b477834ba464471b28563ccdda521d1f2d00e64 100644 --- a/src/InSillyCloWeb/assemblies/forms.py +++ b/src/InSillyCloWeb/assemblies/forms.py @@ -1,7 +1,8 @@ -from django import forms -from crispy_forms.helper import FormHelper from crispy_forms import layout +from crispy_forms.helper import FormHelper +from django import forms from django.forms import formset_factory +from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from assemblies.models import Assembly, InputPart @@ -18,23 +19,57 @@ class AssemblyForm(forms.ModelForm): model = Assembly fields = ( 'owner', + 'owning_session', 'name', - 'is_output_typed', 'output_separator', 'restriction_enzyme', - 'is_input_part_typed', ) widgets = { 'owner': forms.HiddenInput(), + 'owning_session': forms.HiddenInput(), + } + labels = { + "name": _("Name of the assembly"), + # "restriction_enzyme": _("Select the restriction enzyme used for the assembly."), + "output_separator": _('Naming convention for the output separator'), + } + help_texts = { + "name": _("Name of the assembly"), + "output_separator": _( + "<span class=\"highlighted-help-texts\">" + "When computing the name of the output plasmid, what separator would you like to use to separate your input part names ?" + "</span>" + "<br/>" + " example: with ' - ' as separator, assembling pTDH3, GFP, tADH1 and a backbone would give pTDH3-GFP-tADH1-backbone" + ), + "restriction_enzyme": _( + "<span class=\"highlighted-help-texts\">" + "Select the restriction enzyme used for the assembly." + "</span>" + ), } - def __init__(self, owner, *args, **kwargs): + def __init__(self, owner, owning_session, *args, **kwargs): self.owner = owner + self.owning_session = owning_session super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = layout.Layout( + layout.Fieldset( + '', + 'owner', + 'owning_session', + 'name', + 'output_separator', + 'restriction_enzyme', + ), + ) def clean(self): cleaned_data = super().clean() cleaned_data['owner'] = self.owner + cleaned_data['owning_session'] = self.owning_session return cleaned_data @@ -45,32 +80,37 @@ class InputPartForm(forms.ModelForm): 'name', 'is_in_output_plasmid_name', 'is_mandatory', + 'is_typed', 'number_of_subparts_str', 'subpart_separator', 'order', ) widgets = { - # 'order': forms.HiddenInput(), - # 'number_of_subparts_str': forms.HiddenInput(), + 'order': forms.HiddenInput(), + 'number_of_subparts_str': forms.HiddenInput(), } - - helper = FormHelper() - helper.form_tag = False - helper.layout = layout.Layout( - layout.Row( - layout.Column( - 'order', - 'name', - 'is_in_output_plasmid_name', - 'is_mandatory', - 'number_of_subparts_str', - 'subpart_separator', - layout.Div( - "DELETE", - css_class='d-none formset-delete-parent', - ), + labels = { + "name": _("Name of the INPUT PART"), + "output_separator": _('Naming convention for the output separator'), + "is_mandatory": _( + "The input part created is mandatory. If marked as mandatory, the Assembly Simulator will raise an error if this part is missing in your assembly." + ), + "is_in_output_plasmid_name": _("The name of the input part should be included in the output plasmid name."), + "is_typed": _( + "The input part will be defined not only by its name, but also with a type. " + "It is made to keep names simple when a part can take different locations in an assembly. " + ), + } + help_texts = { + "is_typed": _( + "For instance, instead of two parts names GFP~Nter and GFP~Cter, " + "they will both be named GFP but the former will have the type Nter and the latter the type Cter." ) - ) + } + + is_separable = forms.BooleanField( + label=_("The input part can be separated in subparts."), + required=False, ) is_separable_in_sub_parts = forms.BooleanField( @@ -79,14 +119,117 @@ class InputPartForm(forms.ModelForm): required=False, ) + nb_subpart_allowed = forms.MultipleChoiceField( + choices=[(str(i), str(i)) for i in range(1, 7)], + required=False, + widget=forms.CheckboxSelectMultiple, + label=_("Number of subpart"), + ) + def __init__(self, *args, **kwargs): # self.assembly = assembly super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + asterisk = mark_safe('<span class="asteriskField">*</span>') + self.helper.layout = layout.Layout( + layout.Div( + layout.Div( + layout.Div( + layout.HTML( + _('Input Part - '), + ), + ), + layout.Div( + layout.HTML( + 'loading', + ), + css_class='name-header', + ), + layout.Div( + css_class='change-order-parent', + ), + css_class='ip-header-row', + ), + ), + layout.Row( + layout.Column( + layout.Fieldset( + '', + 'name', + # InlineField('name', readonly=True), + css_class='name-parent', + ), + layout.Fieldset( + _('Used in output plasmid name') + asterisk, + 'is_in_output_plasmid_name', + ), + layout.Fieldset( + _('Mandatory part') + asterisk, + 'is_mandatory', + ), + layout.Fieldset( + _('Input part typed') + asterisk, + 'is_typed', + css_class='is-typed-parent', + ), + layout.Fieldset( + _('Separable in sub-parts') + asterisk, + layout.Div( + 'is_separable', + css_class='is-separable-parent', + ), + layout.Div( + 'subpart_separator', + css_class='subpart-separator-parent separable-controlled', + ), + layout.Div( + 'nb_subpart_allowed', + css_class='nb-subpart-allowed-parent separable-controlled typed-controlled sub-fieldset-m-0', + ), + layout.Div( + layout.HTML( + '<span class="form-label ">' + _("Input Part type : ") + '</span><br/>', + ), + layout.HTML( + _( + "The parameters you defined earlier (separable, separator, and input part typed) allows" + " you to define this type of input part:" + ), + ), + css_class='separable-controlled typed-controlled', + ), + layout.Div( + css_class='subpart-target mt-2 separable-controlled typed-controlled', + ), + ), + layout.Div( + "DELETE", + css_class='d-none formset-delete-parent', + ), + layout.Div( + 'order', + css_class='order-parent', + ), + ), + css_class='collapsible-form-row collapse show', + ), + layout.Div( + 'number_of_subparts_str', + css_class='num-sub-parts-str', + ), + ) + self.fields['nb_subpart_allowed'].label = mark_safe(self.fields['nb_subpart_allowed'].label + asterisk) - # def clean(self): - # cleaned_data = super().clean() - # cleaned_data['assembly'] = self.assembly - # return cleaned_data + def clean(self): + cleaned_data = super().clean() + if self.errors: + return cleaned_data + if cleaned_data["is_typed"] and not cleaned_data["is_separable"]: + cleaned_data["number_of_subparts_str"] = f"[1]" + if len(cleaned_data["nb_subpart_allowed"]) == 0 and cleaned_data["is_separable"] and cleaned_data["is_typed"]: + self.add_error('nb_subpart_allowed', _('You have to specify at least one configuration')) + return cleaned_data InputPartFormFormSet = formset_factory( diff --git a/src/InSillyCloWeb/assemblies/locale/en/LC_MESSAGES/django.po b/src/InSillyCloWeb/assemblies/locale/en/LC_MESSAGES/django.po index 8458292db2e4a1eaaa807924dcc81efe80e9a973..07158ba13699708586634e849bfe91aca61771ed 100644 --- a/src/InSillyCloWeb/assemblies/locale/en/LC_MESSAGES/django.po +++ b/src/InSillyCloWeb/assemblies/locale/en/LC_MESSAGES/django.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-26 10:38+0100\n" +"POT-Creation-Date: 2025-04-04 15:25+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -14,6 +14,70 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: assemblies/forms.py +msgid "Name of the assembly" +msgstr "" + +#: assemblies/forms.py +msgid "Naming convention for the output separator" +msgstr "" + +#: assemblies/forms.py +msgid "<span class=\"highlighted-help-texts\">When computing the name of the output plasmid, what separator would you like to use to separate your input part names ?</span><br/> example: with ' - ' as separator, assembling pTDH3, GFP, tADH1 and a backbone would give pTDH3-GFP-tADH1-backbone" +msgstr "" + +#: assemblies/forms.py +msgid "<span class=\"highlighted-help-texts\">Select the restriction enzyme used for the assembly.</span>" +msgstr "" + +#: assemblies/forms.py +msgid "Name of the INPUT PART" +msgstr "" + +#: assemblies/forms.py +msgid "The input part created is mandatory. If marked as mandatory, the Assembly Simulator will raise an error if this part is missing in your assembly." +msgstr "" + +#: assemblies/forms.py +msgid "The name of the input part should be included in the output plasmid name." +msgstr "" + +#: assemblies/forms.py +msgid "The input part can be separated in subparts." +msgstr "" + +#: assemblies/forms.py +msgid "The input part will be defined not only by its name, but also with a type. It is made to keep names simple when a part can take different locations in an assembly. " +msgstr "" + +#: assemblies/forms.py +msgid "For instance, instead of two parts names GFP~Nter and GFP~Cter, they will both be named GFP but the former will have the type Nter and the latter the type Cter." +msgstr "" + +#: assemblies/forms.py +msgid "InputPartForm.is_separable_in_sub_parts.label" +msgstr "" + +#: assemblies/forms.py +msgid "InputPartForm.is_separable_in_sub_parts.help_text" +msgstr "" + +#: assemblies/forms.py +msgid "Used in output plasmid name" +msgstr "" + +#: assemblies/forms.py +msgid "Mandatory part" +msgstr "" + +#: assemblies/forms.py +msgid "Input part typed" +msgstr "" + +#: assemblies/forms.py +msgid "Separable in sub-parts" +msgstr "" + #: assemblies/forms.py msgid "Name" msgstr "" @@ -27,16 +91,61 @@ msgid "Type of the output plasmid" msgstr "" #: assemblies/models.py -msgid "email" +msgid "Comma" +msgstr "" + +#: assemblies/models.py +msgid "Hyphen" +msgstr "" + +#: assemblies/models.py +msgid "Dot" +msgstr "" + +#: assemblies/models.py +msgid "Tilde" +msgstr "" + +#: assemblies/models.py +msgid "Restriction Enzyme" msgstr "" #: assemblies/models.py -msgid "is_output_typed display when true" -msgstr "A type can be assigned to the output plasmids" +msgid "Restriction Enzymes" +msgstr "" + +#: assemblies/models.py +msgid "Input Part" +msgstr "" #: assemblies/models.py -msgid "is_output_typed display when false" -msgstr "Output plasmids have no type" +msgid "Input Parts" +msgstr "" + +#: assemblies/models.py +msgid "Assembly" +msgstr "" + +#: assemblies/models.py +msgid "Assemblies" +msgstr "" + +#: assemblies/templates/assemblies/assembly_card.html +#: assemblies/templates/assemblies/assembly_detail_content.html +#: assemblies/templates/assemblies/index.html +#: assemblies/templates/assemblies/input_part_card.html +msgid ":" +msgstr "" + +#: assemblies/templates/assemblies/assembly_detail_content.html +#, fuzzy +#| msgid "Assembly designer subtitle" +msgid "Assembly properties" +msgstr "Define your own assembly<br/>(number of parts, naming convention...)" + +#: assemblies/templates/assemblies/assembly_detail_content.html +msgid "Input parts" +msgstr "" #: assemblies/templates/assemblies/base.html msgid "tool_name" @@ -108,10 +217,6 @@ msgstr "InSillyClo is a powerful, time-saving tool for efficient cloning project msgid "Key features" msgstr "" -#: assemblies/templates/assemblies/index.html -msgid ":" -msgstr "" - #: assemblies/templates/assemblies/index.html msgid "optional" msgstr "" @@ -141,12 +246,28 @@ msgstr "" msgid "Data privacy policy" msgstr "" -#: assemblies/templates/assemblies/nav_bar.html -msgid "Home" +#: assemblies/templates/assemblies/input_part_card.html +msgid "IP" +msgstr "" + +#: assemblies/templates/assemblies/input_part_card.html +msgid "The input part created is <b>mandatory</b>" +msgstr "" + +#: assemblies/templates/assemblies/input_part_card.html +msgid "The input part created is optional" +msgstr "" + +#: assemblies/templates/assemblies/input_part_card.html +msgid "The input part <b>cannot</b> be separated in subparts" +msgstr "" + +#: assemblies/templates/assemblies/input_part_card.html +msgid "Input part allowed types:" msgstr "" #: assemblies/templates/assemblies/nav_bar.html -msgid "Home logged in" +msgid "Home" msgstr "" #: assemblies/templates/assemblies/nav_bar.html @@ -166,6 +287,10 @@ msgstr "" msgid "Form Example" msgstr "" +#: assemblies/templates/assemblies/nav_bar_end.html +msgid "View profile" +msgstr "" + #: assemblies/templates/assemblies/nav_bar_end.html msgid "Login" msgstr "" @@ -207,13 +332,30 @@ msgstr "" msgid "Workflow details" msgstr "" -msgid "Prev step" -msgstr "Return" - #: assemblies/templates/assemblies/tutorial.html msgid "At the core of InSillyClo lies the golden-gate assembly of input plasmids into an output plasmid." msgstr "" +#: assemblies/templates/assemblies/wizard_view_form_host.html +msgid "clear form" +msgstr "" + +#: assemblies/templates/assemblies/wizard_view_form_host.html +#, fuzzy +#| msgid "Prev step" +msgid "first step" +msgstr "Return" + +#: assemblies/templates/assemblies/wizard_view_form_host.html +#, fuzzy +#| msgid "Prev step" +msgid "prev step" +msgstr "Return" + +#: assemblies/wizard_views.py +msgid "Next" +msgstr "" + #: assemblies/wizard_views.py msgid "AssemblyDesignerWizard.title" msgstr "Assembly Designer" @@ -224,21 +366,24 @@ msgstr "Assembly Designer" #: assemblies/wizard_views.py msgid "AssemblyDesignerWizard.Intro.step_message" +msgstr "<b class=\"text-primary\">Design Your Cloning, Your Way with InSillyClo</b><br/>InSillyClo gives you full control over your assembly design. Tailor each step, define your plasmid assembly, and optimize workflows for efficient, customized cloning - all in one tool.<br/><br/>Many Modular cloning kits have been developed. Although they all share the golden gate assembly, they each have specificities. Assembly Designer was though to allow each user to define the assembly type corresponding to their cloning workflow. InSillyClo also support basic golden gate assembly." + +#: assemblies/wizard_views.py +msgid "AssemblyDesignerWizard.Properties.form_title" +msgstr "Assembly Properties" + +#: assemblies/wizard_views.py +msgid "AssemblyDesignerWizard.InputParts.form_title" +msgstr "Assembly input parts" + +#: assemblies/wizard_views.py +msgid "Add input part" msgstr "" -"<b class=\"text-primary\">Design Your Cloning, Your Way with InSillyClo</b><br/>" -"InSillyClo gives you full control over your assembly design. Tailor each step, define your plasmid assembly, and " -"optimize workflows for efficient, customized cloning - all in one tool.<br/><br/>" -"Many Modular cloning kits have been developed. Although they all share the golden gate assembly, " -"they each have specificities. Assembly Designer was though to allow each user to define the assembly type corresponding to their cloning workflow. InSillyClo also support basic golden gate assembly." #: assemblies/wizard_views.py msgid "AssemblyDesignerWizard.Summary.form_title" msgstr "Resume" -#: assemblies/wizard_views.py -msgid "AssemblyDesignerWizard.Properties.form_title" -msgstr "Assembly Properties" - #: assemblies/wizard_views.py msgid "AssemblyDesignerWizard.Summary.submit_text" msgstr "Save assembly type" \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/management/commands/load_demo.py b/src/InSillyCloWeb/assemblies/management/commands/load_demo.py index 975589775ec63ed6ee8191c0e457b2ba910e987c..1fc000cb3ee029fde942dbe3b370543f2b143b1f 100644 --- a/src/InSillyCloWeb/assemblies/management/commands/load_demo.py +++ b/src/InSillyCloWeb/assemblies/management/commands/load_demo.py @@ -36,12 +36,12 @@ def load_demo(seed=0, ignore_idempotent=False): is_public=True, owner=public_owner, ) - idempotent_check(fake, ignore_idempotent, 199) + idempotent_check(fake, ignore_idempotent, 620) for i in range(2): create_assembly( fake=fake, ) - idempotent_check(fake, ignore_idempotent, 285) + idempotent_check(fake, ignore_idempotent, 457) def get_public_owner(fake): @@ -110,7 +110,17 @@ def create_input_part( order: int = None, ) -> models.InputPart: if fake.random_int(0, 1) == 1: - number_of_subparts_str = json.dumps(list(random_sub_array(fake, list(range(1, 5)), min_cpt=1))) + subpart_separator = models.Separator.choices[ + fake.random_int( + 0, + len(models.Separator.choices) - 1, + ) + ][0] + else: + subpart_separator = None + is_typed = fake.random_int(0, 1) == 1 + if is_typed and subpart_separator: + number_of_subparts_str = json.dumps(list(random_sub_array(fake, list(range(1, 5)), min_cpt=2))) else: number_of_subparts_str = None if order is None: @@ -121,12 +131,8 @@ def create_input_part( order=order, is_in_output_plasmid_name=fake.random_int(0, 1) == 1, is_mandatory=fake.random_int(0, 1) == 1, - subpart_separator=models.Separator.choices[ - fake.random_int( - 0, - len(models.Separator.choices) - 1, - ) - ][0], + is_typed=is_typed, + subpart_separator=subpart_separator, number_of_subparts_str=number_of_subparts_str, ) return ip @@ -144,7 +150,6 @@ def create_assembly( name=fake.sentence(fake.random_int(2, 6)), is_public=is_public, owner=owner, - is_input_part_typed=fake.random_int(0, 1) == 1, output_separator=models.Separator.choices[ fake.random_int( 0, diff --git a/src/InSillyCloWeb/assemblies/migrations/0002_restrictionenzyme_assembly.py b/src/InSillyCloWeb/assemblies/migrations/0002_restrictionenzyme_assembly.py index 99cfef67cba55071ff2b38ea4a613eebbac3973b..215f2abd5e401f9f64ab5a28cd7a275a39097266 100644 --- a/src/InSillyCloWeb/assemblies/migrations/0002_restrictionenzyme_assembly.py +++ b/src/InSillyCloWeb/assemblies/migrations/0002_restrictionenzyme_assembly.py @@ -52,7 +52,7 @@ class Migration(migrations.Migration): max_length=1, ), ), - ('is_input_part_typed', models.BooleanField()), + ('is_input_part_typed', models.BooleanField(default=False)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ( @@ -61,6 +61,7 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, related_name='assemblies', to=settings.AUTH_USER_MODEL, + null=True, ), ), ( diff --git a/src/InSillyCloWeb/assemblies/migrations/0006_alter_inputpart_options_and_more.py b/src/InSillyCloWeb/assemblies/migrations/0006_alter_inputpart_options_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..c1c5b6f1648bd2e51f2cea1adac0e86d37ac79d0 --- /dev/null +++ b/src/InSillyCloWeb/assemblies/migrations/0006_alter_inputpart_options_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.1.8 on 2025-04-10 08:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assemblies', '0005_restrictionenzyme_inter_s_restrictionenzyme_site_for_and_more'), + ('sessions', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='inputpart', + options={ + 'ordering': ('assembly', 'order'), + 'verbose_name': 'Input Part', + 'verbose_name_plural': 'Input Parts', + }, + ), + migrations.RemoveField( + model_name='assembly', + name='is_input_part_typed', + ), + migrations.RemoveField( + model_name='assembly', + name='is_output_typed', + ), + migrations.AddField( + model_name='assembly', + name='owning_session', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='assemblies', + to='sessions.session', + ), + ), + migrations.AddField( + model_name='inputpart', + name='is_typed', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='assembly', + name='owner', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='assemblies', + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name='inputpart', + name='is_in_output_plasmid_name', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='inputpart', + name='is_mandatory', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/InSillyCloWeb/assemblies/mixins.py b/src/InSillyCloWeb/assemblies/mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..03c58f3f00911a6f958eb2f2d9ec5b8df8f783fa --- /dev/null +++ b/src/InSillyCloWeb/assemblies/mixins.py @@ -0,0 +1,22 @@ +import abc + +from django.db.models import Q + + +class AbstractRequestBasedAccessMixin(abc.ABC): + def get_queryset(self): + return self.get_request_based_filtered_queryset( + request=self.request, + queryset=super().get_queryset(), + ) + + @abc.abstractmethod + def get_request_based_filtered_queryset(self, request, queryset): + pass + + +class OnlyVisibleAssemblyMixin(AbstractRequestBasedAccessMixin): + def get_request_based_filtered_queryset(self, request, queryset): + if request.user.is_authenticated: + return queryset.filter(Q(owner=request.user) | Q(is_public=True)) + return queryset.filter(Q(owning_session__pk=request.session.session_key) | Q(is_public=True)) diff --git a/src/InSillyCloWeb/assemblies/models.py b/src/InSillyCloWeb/assemblies/models.py index 6168dd103b94d96c1165eb1d191e8f5f77c9ba32..d2dd6116506cb0d0dce7dceeb328b25080e56001 100644 --- a/src/InSillyCloWeb/assemblies/models.py +++ b/src/InSillyCloWeb/assemblies/models.py @@ -6,6 +6,7 @@ from tempfile import NamedTemporaryFile from typing import List, Tuple from django.contrib.auth import models as auth_models +from django.contrib.sessions.models import Session from django.db import models from django.db.models.functions import Upper from django.urls import reverse @@ -89,8 +90,15 @@ class InputPart(models.Model): null=False, blank=False, ) - is_in_output_plasmid_name = models.BooleanField() - is_mandatory = models.BooleanField() + is_in_output_plasmid_name = models.BooleanField( + default=True, + ) + is_mandatory = models.BooleanField( + default=True, + ) + is_typed = models.BooleanField( + default=False, + ) number_of_subparts_str = models.CharField( null=True, blank=True, @@ -113,7 +121,8 @@ class InputPart(models.Model): @property def is_separable(self) -> bool: - return self.number_of_subparts_str is not None and self.number_of_subparts_str is not "" + # return self.number_of_subparts_str is not None and self.number_of_subparts_str is not "" + return self.subpart_separator is not None @property def number_of_subparts(self) -> List[int]: @@ -137,9 +146,12 @@ class InputPart(models.Model): return self.name def as_insillyclo_cli_instance(self): + part_types = list(self.allowed_subparts) + if len(part_types) == 0: + part_types = None return insillyclo.models.InputPart( name=self.name, - part_types=list(self.allowed_subparts), + part_types=part_types, is_optional=not self.is_mandatory, in_output_name=self.is_in_output_plasmid_name, separator=self.subpart_separator, @@ -156,6 +168,7 @@ class Assembly(models.Model): name = models.CharField( max_length=255, + # verbose_name=_("Name"), ) is_public = models.BooleanField( default=False, @@ -164,20 +177,29 @@ class Assembly(models.Model): User, on_delete=models.CASCADE, related_name='assemblies', + null=True, + blank=True, + ) + owning_session = models.ForeignKey( + Session, + on_delete=models.CASCADE, + related_name='assemblies', + null=True, + blank=True, ) output_separator = models.CharField( max_length=1, choices=Separator.choices, default=Separator.DOT, - ) - is_output_typed = models.BooleanField( - default=False, + # verbose_name=_("Assembly output_separator label"), + # help_text=_("Assembly output_separator help_text"), ) restriction_enzyme = models.ForeignKey( RestrictionEnzyme, on_delete=models.PROTECT, + # verbose_name=_("Assembly restriction_enzyme label"), + # help_text=_("Assembly restriction_enzyme help_text"), ) - is_input_part_typed = models.BooleanField() created_at = models.DateTimeField( auto_now_add=True, ) @@ -185,22 +207,18 @@ class Assembly(models.Model): auto_now=True, ) - def get_is_output_typed_display(self): - if self.is_output_typed: - return _('is_output_typed display when true') - return _('is_output_typed display when false') - def __str__(self): return self.name - def get_template(self, observer) -> Tuple[BytesIO, str, str]: + def get_template(self, observer, input_parts: List[InputPart] = None) -> Tuple[BytesIO, str, str]: filename = re.sub(r'[^\w .-]', '', self.name) + '.xlsx' filename = filename.replace("..", ".") with NamedTemporaryFile(suffix=".xlsx") as template: destination_file = pathlib.Path(template.name) + input_parts = self.input_parts.order_by('order') if input_parts is None else input_parts insillyclo.template_generator.make_template( destination_file=destination_file, - input_parts=[ip.as_insillyclo_cli_instance() for ip in self.input_parts.order_by('order')], + input_parts=[ip.as_insillyclo_cli_instance() for ip in input_parts], observer=observer, data_source=insillyclo.data_source.DataSourceHardCodedImplementation(), default_plasmid=[], diff --git a/src/InSillyCloWeb/assemblies/static/css/main.css b/src/InSillyCloWeb/assemblies/static/css/main.css index 8e026ab85bb52c3430d29387ee8fc3a3db5dce04..af32bcca1722da1d07a73ae689d6ba2edb6885fc 100644 --- a/src/InSillyCloWeb/assemblies/static/css/main.css +++ b/src/InSillyCloWeb/assemblies/static/css/main.css @@ -16,4 +16,8 @@ } footer .nav-item.login{ display: none; +} +.sub-fieldset-m-0 fieldset, +.sub-fieldset-m-0 fieldset legend~*{ + margin: 0; } \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/static/js/input_part_widget.js b/src/InSillyCloWeb/assemblies/static/js/input_part_widget.js new file mode 100644 index 0000000000000000000000000000000000000000..27b1d4ab30b6b44d40d82f1ec94c44abbb93a73b --- /dev/null +++ b/src/InSillyCloWeb/assemblies/static/js/input_part_widget.js @@ -0,0 +1,112 @@ +function update_sub_part_widgets(e){ + setTimeout(() => { + const subpart_ui = e.target.closest(".formset-row").querySelector('.subpart-target'); + Array.from(subpart_ui.children).forEach(c => c.remove()) + + const order=e.target.closest(".formset-row").querySelector('.order-parent').querySelector('input').value.toString(); + + const sep=e.target.closest(".formset-row").querySelector('.subpart-separator-parent').querySelector('select').value.toString(); + + const subparts = []; + const letters = 'abcdefgh'; + + for (const chosen of e.target.closest(".nb-subpart-allowed-parent").querySelectorAll('[type="checkbox"]:checked')) { + subparts.push(chosen.value) + var subpart_ui_html="<span class=\"heading\">"+chosen.value+"</span>"; + if (chosen.value === '1'){ + subpart_ui_html += "<span class=\"type\">"+order+"</span>"; + } else { + for (let step = 0; step < chosen.value; step++) { + if (step > 0){ + subpart_ui_html += '<span class="sep">'+sep+'</span>'; + } + subpart_ui_html += '<span class="type">' + order+letters[step] + "</span>" + } + } + subpart_ui.appendChild(stringToHTML("<div>" + subpart_ui_html + "</div>")); + } + const dst = e.target.closest(".formset-row").querySelector('.num-sub-parts-str').querySelector('input'); + //console.log(dst); + dst.value = "[" + subparts.join(', ') + "]" + }, 50) +} + +function update_sep_in_sub_part_widgets(e){ + const sep=e.target.closest(".formset-row").querySelector('.subpart-separator-parent').querySelector('select').value.toString(); + + for (const sep_span of e.target.closest(".formset-row").querySelector('.subpart-target').querySelectorAll(".sep")){ + sep_span.innerText=sep + } +} + +function change_order(ip, shift){ + console.log(ip) + console.log(shift) + const old_order = ip.querySelector('.order-parent').querySelector('input').value; + const new_order = parseInt(old_order) + shift; + console.log(new_order) + for (const an_ip of ip.closest(".formset-container").querySelectorAll(".formset-row")){ + if (new_order === an_ip.querySelector('.order-parent').querySelector('input').value){ + an_ip.querySelector('.order-parent').querySelector('input').value = old_order + } + } + ip.querySelector('.order-parent').querySelector('input').value=new_order.toString(); +} + +document.addEventListener("DOMContentLoaded", function () { + for (const elt of document.querySelectorAll('.formset-row')) { + prepare_input_part_widgets(elt); + } +}); +function prepare_input_part_widgets(root){ + for (const elt of root.querySelector('.nb-subpart-allowed-parent') + .querySelectorAll('.form-check-label')) { + elt.addEventListener('click', update_sub_part_widgets); + } + root.querySelector('.subpart-separator-parent') + .querySelector('select') + .addEventListener('change', update_sep_in_sub_part_widgets); + root.querySelector('.name-parent') + .querySelector('input') + .addEventListener('keyup', function(e) { + e.target.closest(".formset-row").querySelector('.name-header').innerText=( + e.target.value === '' ? '???' : e.target.value + ) + }); + root.querySelector('.is-separable-parent') + .querySelector('[type="checkbox"]') + .addEventListener('change', function(e) { + const elements = e.target.closest(".formset-row").querySelectorAll('.separable-controlled'); + if (e.target.checked) + for (const sub_elt of elements) sub_elt.classList.add("separable-shown") + else + for (const sub_elt of elements) sub_elt.classList.remove("separable-shown") + }); + root.querySelector('.is-typed-parent') + .querySelector('[type="checkbox"]') + .addEventListener('change', function(e) { + const elements = e.target.closest(".formset-row").querySelectorAll('.typed-controlled'); + if (e.target.checked) + for (const sub_elt of elements) sub_elt.classList.add("typed-shown") + else + for (const sub_elt of elements) sub_elt.classList.remove("typed-shown") + }); + + + root.querySelector('.name-parent') + .querySelector('input') + .dispatchEvent(new KeyboardEvent('keyup', { key:' ' })); + for (const elt of root.querySelector('.nb-subpart-allowed-parent') + .querySelectorAll('.form-check-label')) { + elt.dispatchEvent(new KeyboardEvent('click', {})); + } + for(const elt of root.querySelectorAll('[type="checkbox"]')){ + elt.dispatchEvent(new KeyboardEvent('change', {})); + } + /*root.querySelector('.order-minus-one').addEventListener('click', function(e) { + change_order(e.target.closest(".formset-row"), -1) + }); + root.querySelector('.order-plus-one').addEventListener('click', function(e) { + change_order(e.target.closest(".formset-row"), 1) + });*/ +} \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/static/js/netsted_formset.js b/src/InSillyCloWeb/assemblies/static/js/nested_formset.js similarity index 84% rename from src/InSillyCloWeb/assemblies/static/js/netsted_formset.js rename to src/InSillyCloWeb/assemblies/static/js/nested_formset.js index 995b625221de786000f03a1257bd6ed84c17dc04..e9d375ba04397262913daf6fd0d79453c0e4d36e 100644 --- a/src/InSillyCloWeb/assemblies/static/js/netsted_formset.js +++ b/src/InSillyCloWeb/assemblies/static/js/nested_formset.js @@ -1,7 +1,7 @@ function stringToHTML (str) { const parser = new DOMParser(); const doc = parser.parseFromString(str, 'text/html'); - return doc.body; + return doc.body.children[0]; } function add_form_to_nested_formset(source, prefix) { @@ -27,12 +27,14 @@ function add_form_to_nested_formset(source, prefix) { let formset_new_item = source.closest(".formset-container").querySelector(".formset-new-item"); formset_new_item.parentNode.insertBefore(empty_form, formset_new_item); input_total.value = input_total_value + 1; + if (empty_form.querySelector(".order-parent")) // only for insillyclo + empty_form.querySelector(".order-parent").querySelector("input").value=input_total.value; return empty_form; } document.addEventListener("DOMContentLoaded", function () { document.querySelector('.formset-new-item') .addEventListener('click', function (e) { - add_form_to_nested_formset(e.target); + prepare_input_part_widgets(add_form_to_nested_formset(e.target)); }); }); \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/static/theme.scss b/src/InSillyCloWeb/assemblies/static/theme.scss index 320592bea9d619c5f226e20c43cc73f5bb677974..b20201e4bbc1b346c24b200dc15918edb23932ff 100644 --- a/src/InSillyCloWeb/assemblies/static/theme.scss +++ b/src/InSillyCloWeb/assemblies/static/theme.scss @@ -496,4 +496,112 @@ $nav-pills-link-active-bg:$secondary; border-radius: var(--isc-card-border-radius); box-shadow: $box-shadow; margin-bottom: var(--isc-card-spacer-y); +} +.form-text>.highlighted-help-texts{ + color: var(--isc-body-color); + margin-bottom: 0.3rem; + font-style: normal; +} +.form-text{ + font-style: italic; +} +.form-label{ + color:$headings-color; + text-transform: uppercase !important; + font-weight: $headings-font-weight; +} +legend>.asteriskField, +label>.asteriskField{ + color:$color-orange-crayola; + padding-left: 10px; +} + + +.nb-subpart-allowed-parent .form-check{ + display: inline; +} +.nb-subpart-allowed-parent .form-check .form-check-input{ + float: unset; + display: none; +} +.nb-subpart-allowed-parent .form-check{ + padding-left: unset; +} +.nb-subpart-allowed-parent .form-check .form-check-input+.form-check-label{ + border: $color-dive-blue 1px solid; + min-width: 2.2em; + min-height: 2.2em; + padding-top: 4px; + text-align: center; + border-radius: 10px; + margin-right: 0.5em; +} +.nb-subpart-allowed-parent .form-check .form-check-input:checked+.form-check-label{ + background-color: $color-dive-blue; + color: $color-neutral-light-grey; +} +.subpart-target>div{ + margin-bottom: 1.5em; +} +.subpart-target span.heading{ + border: 1px solid black; + border-radius: 50%; + padding: 8px 15px 8px 15px; + min-width: 40px; + min-height: 40px; + display: inline-block; + margin-right: 3em; + background-color: $color-baby-blue; +} +.subpart-target span.type{ + border: 1px solid black; + border-radius: 10%; + padding: 8px 25px 8px 25px; + min-width: 40px; + display: inline-block; + background-color: $color-neutral-light-grey; +} +.subpart-target span.sep{ + border: 1px solid black; + border-radius: 50%; + padding: 0px 10px 3px 10px; + min-width: 10px; + min-height: 10px; + display: inline-block; + font-weight: 900; + margin-left: -5px; + margin-right: -5px; + background-color: $color-neutral-light-grey; + z-index: 1; + position: relative; +} +.separable-controlled:not(.typed-controlled):not(.separable-shown){ + display: none; +} +.separable-controlled.separable-shown:not(.typed-controlled){ + display: block; +} +.separable-controlled.typed-controlled{ + display: none; +} +.separable-controlled.typed-controlled.separable-shown.typed-shown{ + display: block; +} +.ip-header-row{ + display: flex; + color: $color-dive-blue; + text-transform: uppercase; + font-weight: 700; + font-size: 1.5rem; +} +.ip-header-row>div{ + flex-grow: 1; + text-align: right; +} +.ip-header-row>div+div{ + text-align: left; + font-style: italic; +} +.ip-header-row>div:last-child{ + flex-grow: 0; } \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_card.html b/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_card.html index de7e42870a8ee86ff4a019972914e887f9ea6319..fc6a7f4a200b9d3ed98268c9c3f99a7601e22eea 100644 --- a/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_card.html +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_card.html @@ -30,7 +30,9 @@ </div> <div class="d-block mt-1"> {%for ip in object.input_parts.all %} - <span class="px-3 py-1 border border-{{ip.is_mandatory|yesno:'3,1'}} rounded-5 border-designer">{{ip}}</span> + <div class="d-inline-flex px-3 py-0 mb-1 border border-{{ip.is_mandatory|yesno:'3,1'}} + rounded-5 border-designer" + >{{ip}}</div> {%endfor%} </div> </div> diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_detail_content.html b/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_detail_content.html index 7ba228b7501465a95929a6035d639b549e9da4e7..334fb322025cd086bf1828842a69e3971c7fd7ad 100644 --- a/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_detail_content.html +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/assembly_detail_content.html @@ -39,11 +39,8 @@ </dt> <dd class="col-9"> {{object.get_output_separator_display}} + <code class="border border-1 border-dark px-2 pb-0 pt-1">{{object.output_separator}}</code> </dd> - <dt class="col-11"> - {{object.get_is_output_typed_display}} - </dt> - <dd class="col-1"> </dd> <dt class="col-3"> {{object|field_verbose_name_and_help_text:'restriction_enzyme'}} {%trans ':'%} diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/input_part_card.html b/src/InSillyCloWeb/assemblies/templates/assemblies/input_part_card.html index 3f8b2e51b9b99890fa740192cbdee29ac4f651cd..9bfa37b10af8b90826efd4a43597774398fa800b 100644 --- a/src/InSillyCloWeb/assemblies/templates/assemblies/input_part_card.html +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/input_part_card.html @@ -16,26 +16,30 @@ {%endif%} </div> <div class="col border-10 border-start border-designer ps-md-4"> - <div class="d-block"> - <span class="fw-bolder"> - {{object|field_verbose_name_and_help_text:'subpart_separator'}} - {%trans ':'%} - </span> - {{object.get_subpart_separator_display}} - <code class="border border-1 border-dark px-2 pb-0 pt-1">{{object.subpart_separator}}</code> - </div> <div class="d-block"> <span class=""> {%if object.is_mandatory%} {%trans 'The input part created is <b>mandatory</b>'%} {%else%} - {%trans 'The input part created is optional'%} + {%trans 'The input part created is <b>optional</b>'%} {%endif%} </span> </div> {%if not object.is_separable%} - {%trans "The input part <b>cannot</b> be separated in subparts"%} + {%trans "The input part <b>cannot</b> be separated in subparts"%} + {%elif not object.is_typed%} + {%trans "The input part is <b>not typed</b>"%}<br/> + {%trans "The input part <b>can be</b> separated in subparts using"%} + <code class="border border-1 border-dark px-2 pb-0 pt-1">{{object.subpart_separator}}</code> {%else%} + <div class="d-block"> + <span class="fw-bolder"> + {{object|field_verbose_name_and_help_text:'subpart_separator'}} + {%trans ':'%} + </span> + {{object.get_subpart_separator_display}} + <code class="border border-1 border-dark px-2 pb-0 pt-1">{{object.subpart_separator}}</code> + </div> <div class="d-block"> <span class="fw-bolder"> {%trans 'Input part allowed types:'%} diff --git a/src/InSillyCloWeb/assemblies/views.py b/src/InSillyCloWeb/assemblies/views.py index 303ea17e7eec718267b4592f13610172e83956e3..2a490bb40a4d04d74c24cb0ed173d58bafee64c4 100644 --- a/src/InSillyCloWeb/assemblies/views.py +++ b/src/InSillyCloWeb/assemblies/views.py @@ -8,6 +8,7 @@ from django.shortcuts import render, redirect from django.views.generic import ListView, DetailView, View from django.views.generic.detail import SingleObjectMixin +from . import mixins from assemblies import forms, models, utils from assemblies.insillyclo_impl import InSillyCloDjangoMessageObserver @@ -55,6 +56,7 @@ def form_example(request): class AssemblyListView( + mixins.OnlyVisibleAssemblyMixin, ListView, ): model = models.Assembly @@ -68,6 +70,7 @@ class AssemblyListView( class AssemblyDetailView( + mixins.OnlyVisibleAssemblyMixin, DetailView, ): model = models.Assembly diff --git a/src/InSillyCloWeb/assemblies/wizard_views.py b/src/InSillyCloWeb/assemblies/wizard_views.py index f8b5674b6a708d98dfb28ffad135f5d35e4ca032..969b00e813635df327ecb567f84798e5af857df9 100644 --- a/src/InSillyCloWeb/assemblies/wizard_views.py +++ b/src/InSillyCloWeb/assemblies/wizard_views.py @@ -1,4 +1,5 @@ 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.template.loader import render_to_string @@ -39,10 +40,11 @@ class AssemblyDesignerNotProtected(wizard_views.NamedUrlSessionWizardView): context["form_title"] = _("AssemblyDesignerWizard.Properties.form_title") # context["step_message"] = _("AssemblyDesignerWizard.Assembly.step_message") elif step == "InputParts": - context["form_title"] = _("AssemblyDesignerWizard.Properties.form_title") + context["form_title"] = _("AssemblyDesignerWizard.InputParts.form_title") context["add_new_item_to_formset_text"] = _("Add input part") context["extra_js_files"] = [ - "/js/netsted_formset.js", + "/js/nested_formset.js", + "/js/input_part_widget.js", ] elif step == "Summary": context["form_title"] = _("AssemblyDesignerWizard.Summary.form_title") @@ -64,15 +66,24 @@ class AssemblyDesignerNotProtected(wizard_views.NamedUrlSessionWizardView): def get_form_initial(self, step): if step == "Properties" and self.is_edition is False: + return self.get_ownership() + return super().get_form_initial(step) + + def get_ownership(self): + if self.request.user.is_authenticated: return dict( owner=self.request.user, + owning_session=None, ) - return super().get_form_initial(step) + return dict( + owner=None, + owning_session=Session.objects.get(session_key=self.request.session.session_key), + ) def get_form_kwargs(self, step=None): d = super().get_form_kwargs(step=step) if step == "Properties": - d["owner"] = self.request.user + d.update(self.get_ownership()) # elif step == "Summary": # d["agree_label"] = _("AssemblyDesignerWizard.Summary.agree_message") return d diff --git a/src/InSillyCloWeb/requirements.txt b/src/InSillyCloWeb/requirements.txt index 266e1e0678d3f0cb682c41a3aaf549f50c81a351..e6d3b992bc7e6ce1b6a5fdcdd3c2d8c0504f527e 100644 --- a/src/InSillyCloWeb/requirements.txt +++ b/src/InSillyCloWeb/requirements.txt @@ -13,7 +13,7 @@ Faker Bio --extra-index-url https://gitlab.pasteur.fr/api/v4/projects/insillyclo%2Finsillyclo-cli/packages/pypi/simple -insillyclo +insillyclo>=0.2.1 --extra-index-url https://gitlab.pasteur.fr/api/v4/projects/hub%2Fdjango-kubernetes-probes/packages/pypi/simple django-kubernetes-probes --extra-index-url https://gitlab.pasteur.fr/api/v4/projects/bbrancot%2Fdjango-basetheme-bootstrap/packages/pypi/simple