From 5565b9cb9ef6969915a48f41783bcc3cf0bf2ae0 Mon Sep 17 00:00:00 2001 From: jebus Date: Thu, 29 Jan 2026 11:13:34 +1300 Subject: [PATCH] [Fix] UI bugs, Registrations Mail, BTRules Popup, Legacy API fixes (#309) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/309 --- .gitea/actions/setup-envipy/action.yaml | 5 - epdb/legacy_api.py | 57 +++++--- epdb/management/commands/bootstrap.py | 6 - epdb/management/commands/dump_enviformer.py | 2 +- epdb/models.py | 144 ++++++++++++++------ epdb/tasks.py | 27 +++- epdb/views.py | 142 +++++++++++-------- pnpm-workspace.yaml | 3 + static/js/pw.js | 9 +- templates/index/index.html | 4 +- templates/objects/compound_structure.html | 49 +++++++ templates/objects/edge.html | 7 +- templates/objects/joblog.html | 2 +- templates/objects/node.html | 88 ++++++++---- 14 files changed, 391 insertions(+), 154 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/.gitea/actions/setup-envipy/action.yaml b/.gitea/actions/setup-envipy/action.yaml index fa475d56..a05f8aa7 100644 --- a/.gitea/actions/setup-envipy/action.yaml +++ b/.gitea/actions/setup-envipy/action.yaml @@ -48,11 +48,6 @@ runs: shell: bash run: | uv run python scripts/pnpm_wrapper.py install - cat << 'EOF' > pnpm-workspace.yaml - onlyBuiltDependencies: - - '@parcel/watcher' - - '@tailwindcss/oxide' - EOF uv run python scripts/pnpm_wrapper.py run build - name: Wait for Postgres diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py index d4cc4729..b6aab0bf 100644 --- a/epdb/legacy_api.py +++ b/epdb/legacy_api.py @@ -212,7 +212,7 @@ def get_user(request, user_uuid): class GroupMember(Schema): - id: str = Field(None, alias="url") + id: str identifier: str name: str @@ -228,7 +228,7 @@ class GroupSchema(Schema): members: List[GroupMember] = Field([], alias="members") name: str = Field(None, alias="name") ownerid: str = Field(None, alias="owner.url") - ownername: str = Field(None, alias="owner.name") + ownername: str = Field(None, alias="owner.get_name") packages: List["SimplePackage"] = Field([], alias="packages") readers: List[GroupMember] = Field([], alias="readers") writers: List[GroupMember] = Field([], alias="writers") @@ -237,10 +237,10 @@ class GroupSchema(Schema): def resolve_members(obj: Group): res = [] for member in obj.user_member.all(): - res.append(GroupMember(id=member.url, identifier="usermember", name=member.username)) + res.append(GroupMember(id=member.url, identifier="usermember", name=member.get_name())) for member in obj.group_member.all(): - res.append(GroupMember(id=member.url, identifier="groupmember", name=member.name)) + res.append(GroupMember(id=member.url, identifier="groupmember", name=member.get_name())) return res @@ -374,7 +374,7 @@ class PackageSchema(Schema): ).values_list("user", flat=True) ).distinct() - return [{u.id: u.name} for u in users] + return [{u.id: u.get_name()} for u in users] @staticmethod def resolve_writers(obj: Package): @@ -384,7 +384,7 @@ class PackageSchema(Schema): ).values_list("user", flat=True) ).distinct() - return [{u.id: u.name} for u in users] + return [{u.id: u.get_name()} for u in users] @staticmethod def resolve_review_comment(obj): @@ -966,7 +966,12 @@ def create_package_simple_rule( raise ValueError("Not yet implemented!") else: sr = SimpleAmbitRule.create( - p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts + p, + r.name, + r.description, + r.smirks, + r.reactantFilterSmarts, + r.productFilterSmarts, ) return redirect(sr.url) @@ -1119,7 +1124,7 @@ class ReactionSchema(Schema): name: str = Field(None, alias="name") pathways: List["SimplePathway"] = Field([], alias="related_pathways") products: List["ReactionCompoundStructure"] = Field([], alias="products") - references: List[Dict[str, List[str]]] = Field([], alias="references") + references: Dict[str, List[str]] = Field({}, alias="references") reviewStatus: str = Field(None, alias="review_status") scenarios: List["SimpleScenario"] = Field([], alias="scenarios") smirks: str = Field("", alias="smirks") @@ -1135,8 +1140,12 @@ class ReactionSchema(Schema): @staticmethod def resolve_references(obj: Reaction): - # TODO - return [] + rhea_refs = [] + for rhea in obj.get_rhea_identifiers(): + rhea_refs.append(f"{rhea.identifier_value}") + + # TODO UniProt + return {"rheaReferences": rhea_refs, "uniprotCount": []} @staticmethod def resolve_medline_references(obj: Reaction): @@ -1715,7 +1724,7 @@ class EdgeSchema(Schema): id: str = Field(None, alias="url") identifier: str = "edge" name: str = Field(None, alias="name") - reactionName: str = Field(None, alias="edge_label.name") + reactionName: str = Field(None, alias="edge_label.get_name") reactionURI: str = Field(None, alias="edge_label.url") reviewStatus: str = Field(None, alias="review_status") scenarios: List["SimpleScenario"] = Field([], alias="scenarios") @@ -1764,7 +1773,7 @@ class CreateEdge(Schema): @router.post( - "/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge", + "/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge", response={200: str | Any, 403: Error}, ) def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]): @@ -1783,10 +1792,26 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]): if e.edgeAsSmirks: for ed in e.edgeAsSmirks.split(">>")[0].split("\\."): - educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed)) + stand_ed = FormatConverter.standardize(ed, remove_stereo=True) + educts.append( + Node.objects.get( + pathway=pw, + default_node_label=CompoundStructure.objects.get( + compound__package=p, smiles=stand_ed + ).compound.default_structure, + ) + ) for pr in e.edgeAsSmirks.split(">>")[1].split("\\."): - products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr)) + stand_pr = FormatConverter.standardize(pr, remove_stereo=True) + products.append( + Node.objects.get( + pathway=pw, + default_node_label=CompoundStructure.objects.get( + compound__package=p, smiles=stand_pr + ).compound.default_structure, + ) + ) else: for ed in e.educts.split(","): educts.append(Node.objects.get(pathway=pw, url=ed.strip())) @@ -1799,7 +1824,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]): start_nodes=educts, end_nodes=products, rule=None, - name=e.name, + name=None, description=e.edgeReason, ) @@ -1936,7 +1961,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]): if pr.rule: res["id"] = pr.rule.url res["identifier"] = pr.rule.get_rule_identifier() - res["name"] = pr.rule.name + res["name"] = pr.rule.get_name() res["reviewStatus"] = ( "reviewed" if pr.rule.package.reviewed else "unreviewed" ) diff --git a/epdb/management/commands/bootstrap.py b/epdb/management/commands/bootstrap.py index eb74385a..ccc02d14 100644 --- a/epdb/management/commands/bootstrap.py +++ b/epdb/management/commands/bootstrap.py @@ -8,7 +8,6 @@ from epdb.logic import UserManager, GroupManager, PackageManager, SettingManager from epdb.models import ( UserSettingPermission, MLRelativeReasoning, - EnviFormer, Permission, User, ExternalDatabase, @@ -231,7 +230,6 @@ class Command(BaseCommand): package=pack, rule_packages=[mapping["EAWAG-BBD"]], data_packages=[mapping["EAWAG-BBD"]], - eval_packages=[], threshold=0.5, name="ECC - BBD - T0.5", description="ML Relative Reasoning", @@ -239,7 +237,3 @@ class Command(BaseCommand): ml_model.build_dataset() ml_model.build_model() - - # If available, create EnviFormerModel - if s.ENVIFORMER_PRESENT: - EnviFormer.create(pack, "EnviFormer - T0.5", "EnviFormer Model with Threshold 0.5", 0.5) diff --git a/epdb/management/commands/dump_enviformer.py b/epdb/management/commands/dump_enviformer.py index e333248a..8fe2bf33 100644 --- a/epdb/management/commands/dump_enviformer.py +++ b/epdb/management/commands/dump_enviformer.py @@ -47,7 +47,7 @@ class Command(BaseCommand): "description": model.description, "kv": model.kv, "data_packages_uuids": [str(p.uuid) for p in model.data_packages.all()], - "eval_packages_uuids": [str(p.uuid) for p in model.data_packages.all()], + "eval_packages_uuids": [str(p.uuid) for p in model.eval_packages.all()], "threshold": model.threshold, "eval_results": model.eval_results, "multigen_eval": model.multigen_eval, diff --git a/epdb/models.py b/epdb/models.py index 900e1d4a..920fd911 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -77,6 +77,9 @@ class User(AbstractUser): USERNAME_FIELD = "email" REQUIRED_FIELDS = ["username"] + def get_name(self): + return self.username + def save(self, *args, **kwargs): if not self.url: self.url = self._url() @@ -208,7 +211,10 @@ class Group(TimeStampedModel): ) def __str__(self): - return f"{self.name} (pk={self.pk})" + return f"{self.get_name()} (pk={self.pk})" + + def get_name(self): + return self.name def save(self, *args, **kwargs): if not self.url: @@ -596,7 +602,7 @@ class EnviPathModel(TimeStampedModel): res = { "url": self.url, "uuid": str(self.uuid), - "name": self.name, + "name": self.get_name(), } if include_description: @@ -609,11 +615,14 @@ class EnviPathModel(TimeStampedModel): return self.kv.get(k, default) return default + def get_name(self): + return self.name + class Meta: abstract = True def __str__(self): - return f"{self.name} (pk={self.pk})" + return f"{self.get_name()} (pk={self.pk})" class AliasMixin(models.Model): @@ -624,7 +633,7 @@ class AliasMixin(models.Model): @transaction.atomic def add_alias(self, new_alias, set_as_default=False): if set_as_default: - self.aliases.append(self.name) + self.aliases.append(self.get_name()) self.name = new_alias if new_alias in self.aliases: @@ -765,15 +774,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin logger.debug( f"#Structures: {num_structs} - #Standardized SMILES: {len(stand_smiles)}" ) - logger.debug(f"Couldn't infer normalized structure for {self.name} - {self.url}") + logger.debug( + f"Couldn't infer normalized structure for {self.get_name()} - {self.url}" + ) raise ValueError( - f"Couldn't find nor infer normalized structure for {self.name} ({self.url})" + f"Couldn't find nor infer normalized structure for {self.get_name()} ({self.url})" ) else: cs = CompoundStructure.create( self, stand_smiles.pop(), - name="Normalized structure of {}".format(self.name), + name="Normalized structure of {}".format(self.get_name()), description="{} (in its normalized form)".format(self.description), normalized_structure=True, ) @@ -848,8 +859,10 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin if name is not None: # Clean for potential XSS name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Compound {Compound.objects.filter(package=package).count() + 1}" + c.name = name # We have a default here only set the value if it carries some payload @@ -978,7 +991,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin cs = CompoundStructure.create( existing_normalized_compound, structure.smiles, - name=structure.name, + name=structure.get_name(), description=structure.description, normalized_structure=structure.normalized_structure, ) @@ -989,13 +1002,13 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin else: raise ValueError( - f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.name}" + f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.get_name()}" ) else: # Here we can safely use Compound.objects.create as we won't end up in a duplicate new_compound = Compound.objects.create( package=target, - name=self.name, + name=self.get_name(), description=self.description, kv=self.kv.copy() if self.kv else {}, ) @@ -1011,7 +1024,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin canonical_smiles=structure.canonical_smiles, inchikey=structure.inchikey, normalized_structure=structure.normalized_structure, - name=structure.name, + name=structure.get_name(), description=structure.description, kv=structure.kv.copy() if structure.kv else {}, ) @@ -1050,11 +1063,8 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin def half_lifes(self): hls: Dict[Scenario, List[HalfLife]] = defaultdict(list) - for n in self.related_nodes: - for scen in n.scenarios.all().order_by("name"): - for ai in scen.get_additional_information(): - if isinstance(ai, HalfLife): - hls[scen].append(ai) + for cs in self.structures: + hls.update(cs.half_lifes()) return dict(hls) @@ -1104,6 +1114,7 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti # Clean for potential XSS if name is not None: cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if description is not None: cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() @@ -1147,6 +1158,21 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti def is_default_structure(self): return self.compound.default_structure == self + @property + def related_nodes(self): + return Node.objects.filter(node_labels__in=[self], pathway__package=self.compound.package) + + def half_lifes(self): + hls: Dict[Scenario, List[HalfLife]] = defaultdict(list) + + for n in self.related_nodes: + for scen in n.scenarios.all().order_by("name"): + for ai in scen.get_additional_information(): + if isinstance(ai, HalfLife): + hls[scen].append(ai) + + return dict(hls) + class EnzymeLink(EnviPathModel, KEGGIdentifierMixin): rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True) @@ -1218,7 +1244,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): if rule_type == SimpleAmbitRule: new_rule = SimpleAmbitRule.create( package=target, - name=self.name, + name=self.get_name(), description=self.description, smirks=self.smirks, reactant_filter_smarts=self.reactant_filter_smarts, @@ -1232,7 +1258,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): elif rule_type == SimpleRDKitRule: new_rule = SimpleRDKitRule.create( package=target, - name=self.name, + name=self.get_name(), description=self.description, reaction_smarts=self.reaction_smarts, ) @@ -1250,7 +1276,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): new_rule = ParallelRule.create( package=target, simple_rules=new_srs, - name=self.name, + name=self.get_name(), description=self.description, ) @@ -1624,9 +1650,11 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin r = Reaction() r.package = package + # Clean for potential XSS if name is not None and name.strip() != "": r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if description is not None and name.strip() != "": r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() @@ -1674,7 +1702,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin new_reaction = Reaction.create( package=target, - name=self.name, + name=self.get_name(), description=self.description, educts=copied_reaction_educts, products=copied_reaction_products, @@ -1830,7 +1858,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): # We shouldn't lose or make up nodes... if len(nodes) != len(self.nodes): - logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}") + logger.debug( + f"{self.get_name()}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}" + ) links = [e.d3_json() for e in self.edges] @@ -1898,7 +1928,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): "isIncremental": self.kv.get("mode") == "incremental", "isPredicted": self.kv.get("mode") == "predicted", "lastModified": self.modified.strftime("%Y-%m-%d %H:%M:%S"), - "pathwayName": self.name, + "pathwayName": self.get_name(), "reviewStatus": "reviewed" if self.package.reviewed else "unreviewed", "scenarios": [], "upToDate": True, @@ -1941,14 +1971,14 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): if include_pathway_url: row.append(n.pathway.url) - row += [cs.smiles, cs.name, n.depth] + row += [cs.smiles, cs.get_name(), n.depth] edges = self.edges.filter(end_nodes__in=[n]) if len(edges): for e in edges: _row = row.copy() _row.append(e.kv.get("probability")) - _row.append(",".join([r.name for r in e.edge_label.rules.all()])) + _row.append(",".join([r.get_name() for r in e.edge_label.rules.all()])) _row.append(",".join([r.url for r in e.edge_label.rules.all()])) _row.append(e.start_nodes.all()[0].default_node_label.smiles) rows.append(_row) @@ -1979,12 +2009,14 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): if name is not None: # Clean for potential XSS name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}" pw.name = name if description is not None and description.strip() != "": pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() + pw.predicted = predicted pw.save() @@ -2009,7 +2041,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): # deduplicated new_pathway = Pathway.objects.create( package=target, - name=self.name, + name=self.get_name(), description=self.description, setting=self.setting, # TODO copy settings? kv=self.kv.copy() if self.kv else {}, @@ -2039,7 +2071,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): pathway=new_pathway, default_node_label=copied_structure, depth=node.depth, - name=node.name, + name=node.get_name(), description=node.description, kv=node.kv.copy() if node.kv else {}, ) @@ -2063,7 +2095,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): new_edge = Edge.objects.create( pathway=new_pathway, edge_label=copied_reaction, - name=edge.name, + name=edge.get_name(), description=edge.description, kv=edge.kv.copy() if edge.kv else {}, ) @@ -2123,6 +2155,18 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): def _url(self): return "{}/node/{}".format(self.pathway.url, self.uuid) + def get_name(self): + non_generic_name = True + + if self.name == "no name": + non_generic_name = False + + return ( + self.name + if non_generic_name + else f"{self.default_node_label.name} (taken from underlying structure)" + ) + def d3_json(self): app_domain_data = self.get_app_domain_assessment_data() @@ -2135,9 +2179,9 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): "image_svg": IndigoUtils.mol_to_svg( self.default_node_label.smiles, width=40, height=40 ), - "name": self.default_node_label.name, + "name": self.get_name(), "smiles": self.default_node_label.smiles, - "scenarios": [{"name": s.name, "url": s.url} for s in self.scenarios.all()], + "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "app_domain": { "inside_app_domain": app_domain_data["assessment"]["inside_app_domain"] if app_domain_data @@ -2205,7 +2249,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): res = super().simple_json() name = res.get("name", None) if name == "no name": - res["name"] = self.default_node_label.name + res["name"] = self.default_node_label.get_name() return res @@ -2229,18 +2273,24 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): def d3_json(self): edge_json = { - "name": self.name, + "name": self.get_name(), "id": self.url, "url": self.url, "image": self.url + "?image=svg", - "reaction": {"name": self.edge_label.name, "url": self.edge_label.url} + "reaction": { + "name": self.edge_label.get_name(), + "url": self.edge_label.url, + "rules": [ + {"name": r.get_name(), "url": r.url} for r in self.edge_label.rules.all() + ], + } if self.edge_label else None, "multi_step": self.edge_label.multi_step if self.edge_label else False, "reaction_probability": self.kv.get("probability"), "start_node_urls": [x.url for x in self.start_nodes.all()], "end_node_urls": [x.url for x in self.end_nodes.all()], - "scenarios": [{"name": s.name, "url": s.url} for s in self.scenarios.all()], + "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], } for n in self.start_nodes.all(): @@ -2329,10 +2379,22 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): res = super().simple_json() name = res.get("name", None) if name == "no name": - res["name"] = self.edge_label.name + res["name"] = self.edge_label.get_name() return res + def get_name(self): + non_generic_name = True + + if self.name == "no name": + non_generic_name = False + + return ( + self.name + if non_generic_name + else f"{self.edge_label.name} (taken from underlying reaction)" + ) + class EPModel(PolymorphicModel, EnviPathModel): package = models.ForeignKey( @@ -2613,7 +2675,7 @@ class PackageBasedModel(EPModel): root_compounds.append(pw.root_nodes[0].default_node_label) else: logger.info( - f"Skipping MG Eval of Pathway {pw.name} ({pw.uuid}) as it has no root compounds!" + f"Skipping MG Eval of Pathway {pw.get_name()} ({pw.uuid}) as it has no root compounds!" ) # As we need a Model Instance in our setting, get a fresh copy from db, overwrite the serialized mode and @@ -2739,7 +2801,7 @@ class PackageBasedModel(EPModel): pathways.append(pathway) else: logging.warning( - f"No root compound in pathway {pathway.name}, excluding from multigen evaluation" + f"No root compound in pathway {pathway.get_name()}, excluding from multigen evaluation" ) # build lookup reaction -> {uuid1, uuid2} for overlap check @@ -3071,7 +3133,7 @@ class ApplicabilityDomain(EnviPathModel): ad = ApplicabilityDomain() ad.model = mlrr # ad.uuid = mlrr.uuid - ad.name = f"AD for {mlrr.name}" + ad.name = f"AD for {mlrr.get_name()}" ad.num_neighbours = num_neighbours ad.reliability_threshold = reliability_threshold ad.local_compatibilty_threshold = local_compatibility_threshold @@ -3355,7 +3417,7 @@ class EnviFormer(PackageBasedModel): ) for smiles in smiles_list ] - logger.info(f"Submitting {canon_smiles} to {self.name}") + logger.info(f"Submitting {canon_smiles} to {self.get_name()}") start = datetime.now() products_list = self.model.predict_batch(canon_smiles) end = datetime.now() @@ -3512,7 +3574,7 @@ class EnviFormer(PackageBasedModel): root_node = p.root_nodes if len(root_node) > 1: logging.warning( - f"Pathway {p.name} has more than one root compound, only {root_node[0]} will be used" + f"Pathway {p.get_name()} has more than one root compound, only {root_node[0]} will be used" ) root_node = ".".join( [ @@ -3632,7 +3694,7 @@ class EnviFormer(PackageBasedModel): pathways.append(pathway) else: logging.warning( - f"No root compound in pathway {pathway.name}, excluding from multigen evaluation" + f"No root compound in pathway {pathway.get_name()}, excluding from multigen evaluation" ) # build lookup reaction -> {uuid1, uuid2} for overlap check @@ -4038,6 +4100,6 @@ class JobLog(TimeStampedModel): return self.task_result def is_result_downloadable(self): - downloadable = ["batch_predict"] + downloadable = ["batch_predict", "identify_missing_rules"] return self.job_name in downloadable diff --git a/epdb/tasks.py b/epdb/tasks.py index ebffb5b7..b3aaa5e1 100644 --- a/epdb/tasks.py +++ b/epdb/tasks.py @@ -7,6 +7,7 @@ from uuid import uuid4 from celery import shared_task from celery.utils.functional import LRUCache from django.conf import settings as s +from django.core.mail import EmailMultiAlternatives from django.utils import timezone from epdb.logic import SPathway @@ -73,7 +74,31 @@ def predict_simple(model_pk: int, smiles: str): @shared_task(queue="background") def send_registration_mail(user_pk: int): - pass + u = User.objects.get(id=user_pk) + + tpl = """Welcome {username}!, + +Thank you for your interest in enviPath. + +The public system is intended for non-commercial use only. +We will review your account details and usually activate your account within 24 hours. +Once activated, you will be notified by email. + +If we have any questions, we will contact you at this email address. + +Best regards, + +enviPath team""" + + msg = EmailMultiAlternatives( + "Your enviPath account", + tpl.format(username=u.username), + "admin@envipath.org", + [u.email], + bcc=["admin@envipath.org"], + ) + + msg.send(fail_silently=False) @shared_task(bind=True, queue="model") diff --git a/epdb/views.py b/epdb/views.py index 2d1595f2..e657ff3f 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -1,7 +1,7 @@ import json import logging -from typing import Any, Dict, List from datetime import datetime +from typing import Any, Dict, List import nh3 from django.conf import settings as s @@ -13,6 +13,7 @@ from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from envipy_additional_information import NAME_MAPPING from oauth2_provider.decorators import protected_resource +from sentry_sdk import capture_exception from utilities.chem import FormatConverter, IndigoUtils from utilities.decorators import package_permission_required @@ -34,6 +35,7 @@ from .models import ( EnviFormer, EnzymeLink, EPModel, + ExpansionSchemeChoice, ExternalDatabase, ExternalIdentifier, Group, @@ -51,7 +53,6 @@ from .models import ( SimpleAmbitRule, User, UserPackagePermission, - ExpansionSchemeChoice, ) logger = logging.getLogger(__name__) @@ -238,6 +239,15 @@ def register(request): try: u = UserManager.create_user(username, email, password) logger.info(f"Created user {u.username} ({u.pk})") + + try: + from .tasks import send_registration_mail + + send_registration_mail.delay(u.pk) + except Exception as e: + logger.error(f"Failed to send registration mail to {u.email}: {e}") + capture_exception(e) + except Exception: context["message"] = "Registration failed! Couldn't create User Account." return render(request, "static/login.html", context) @@ -339,7 +349,7 @@ def breadcrumbs( {"Package": s.SERVER_URL + "/package"}, ] if first_level_object is not None: - bread.append({first_level_object.name: first_level_object.url}) + bread.append({first_level_object.get_name(): first_level_object.url}) if second_level_namespace is not None: bread.append( @@ -350,7 +360,7 @@ def breadcrumbs( ) if second_level_object is not None: - bread.append({second_level_object.name: second_level_object.url}) + bread.append({second_level_object.get_name(): second_level_object.url}) if third_level_namespace is not None: bread.append( @@ -361,7 +371,7 @@ def breadcrumbs( ) if third_level_object is not None: - bread.append({third_level_object.name: third_level_object.url}) + bread.append({third_level_object.get_name(): third_level_object.url}) return bread @@ -462,7 +472,7 @@ def package_predict_pathway(request, package_uuid): current_package = PackageManager.get_package_by_id(current_user, package_uuid) context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Predict Pathway" + context["title"] = f"enviPath - {current_package.get_name()} - Predict Pathway" context["meta"]["current_package"] = current_package return render(request, "predict_pathway.html", context) @@ -475,6 +485,10 @@ def packages(request): context = get_base_context(request) context["title"] = "enviPath - Packages" context["meta"]["current_package"] = context["meta"]["user"].default_package + context["breadcrumbs"] = [ + {"Home": s.SERVER_URL}, + {"Package": s.SERVER_URL + "/package"}, + ] # Context for paginated template context["entity_type"] = "package" @@ -529,6 +543,10 @@ def compounds(request): context = get_base_context(request) context["title"] = "enviPath - Compounds" context["meta"]["current_package"] = context["meta"]["user"].default_package + context["breadcrumbs"] = [ + {"Home": s.SERVER_URL}, + {"Compound": s.SERVER_URL + "/compound"}, + ] # Context for paginated template context["entity_type"] = "compound" @@ -759,7 +777,7 @@ def package_models(request, package_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Models" + context["title"] = f"enviPath - {current_package.get_name()} - Models" context["meta"]["current_package"] = current_package context["object_type"] = "model" @@ -781,7 +799,7 @@ def package_models(request, package_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_model_qs if current_package.reviewed else unreviewed_model_qs ) @@ -931,7 +949,7 @@ def package_model(request, package_uuid, model_uuid): return JsonResponse(app_domain_assessment, safe=False) context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_model.name}" + context["title"] = f"enviPath - {current_package.get_name()} - {current_model.get_name()}" context["meta"]["current_package"] = current_package context["object_type"] = "model" @@ -1009,7 +1027,7 @@ def package(request, package_uuid): if request.method == "GET": if request.GET.get("export", False) == "true": - filename = f"{current_package.name.replace(' ', '_')}_{current_package.uuid}.json" + filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json" pack_json = PackageManager.export_package( current_package, include_models=False, include_external_identifiers=False ) @@ -1019,7 +1037,7 @@ def package(request, package_uuid): return response context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name}" + context["title"] = f"enviPath - {current_package.get_name()}" context["meta"]["current_package"] = current_package context["object_type"] = "package" @@ -1056,7 +1074,7 @@ def package(request, package_uuid): if current_user.default_package == current_package: return error( request, - f'Package "{current_package.name}" is the default and cannot be deleted!', + f'Package "{current_package.get_name()}" is the default and cannot be deleted!', "You cannot delete the default package. If you want to delete this package you have to set another default package first.", ) @@ -1154,7 +1172,7 @@ def package_compounds(request, package_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Compounds" + context["title"] = f"enviPath - {current_package.get_name()} - Compounds" context["meta"]["current_package"] = current_package context["object_type"] = "compound" @@ -1179,7 +1197,7 @@ def package_compounds(request, package_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_compound_qs if current_package.reviewed @@ -1216,7 +1234,9 @@ def package_compound(request, package_uuid, compound_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_compound.name}" + context["title"] = ( + f"enviPath - {current_package.get_name()} - {current_compound.get_name()}" + ) context["meta"]["current_package"] = current_package context["object_type"] = "compound" @@ -1300,7 +1320,7 @@ def package_compound_structures(request, package_uuid, compound_uuid): if request.method == "GET": context = get_base_context(request) context["title"] = ( - f"enviPath - {current_package.name} - {current_compound.name} - Structures" + f"enviPath - {current_package.get_name()} - {current_compound.get_name()} - Structures" ) context["meta"]["current_package"] = current_package @@ -1309,7 +1329,7 @@ def package_compound_structures(request, package_uuid, compound_uuid): current_package, "compound", current_compound, "structure" ) context["entity_type"] = "structure" - context["page_title"] = f"{current_compound.name} - Structures" + context["page_title"] = f"{current_compound.get_name()} - Structures" context["api_endpoint"] = ( f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/" ) @@ -1362,7 +1382,7 @@ def package_compound_structure(request, package_uuid, compound_uuid, structure_u context = get_base_context(request) context["title"] = ( - f"enviPath - {current_package.name} - {current_compound.name} - {current_structure.name}" + f"enviPath - {current_package.get_name()} - {current_compound.get_name()} - {current_structure.get_name()}" ) context["meta"]["current_package"] = current_package @@ -1468,7 +1488,7 @@ def package_rules(request, package_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Rules" + context["title"] = f"enviPath - {current_package.get_name()} - Rules" context["meta"]["current_package"] = current_package context["object_type"] = "rule" @@ -1490,7 +1510,7 @@ def package_rules(request, package_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_rule_qs if current_package.reviewed else unreviewed_rule_qs ) @@ -1580,7 +1600,7 @@ def package_rule(request, package_uuid, rule_uuid): content_type="image/svg+xml", ) - context["title"] = f"enviPath - {current_package.name} - {current_rule.name}" + context["title"] = f"enviPath - {current_package.get_name()} - {current_rule.get_name()}" context["meta"]["current_package"] = current_package context["object_type"] = "rule" @@ -1653,7 +1673,7 @@ def package_rule_enzymelink(request, package_uuid, rule_uuid, enzymelink_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_rule.name}" + context["title"] = f"enviPath - {current_package.get_name()} - {current_rule.get_name()}" context["meta"]["current_package"] = current_package context["object_type"] = "enzyme" @@ -1676,7 +1696,7 @@ def package_reactions(request, package_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Reactions" + context["title"] = f"enviPath - {current_package.get_name()} - Reactions" context["meta"]["current_package"] = current_package context["object_type"] = "reaction" @@ -1700,7 +1720,7 @@ def package_reactions(request, package_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_reaction_qs if current_package.reviewed @@ -1741,7 +1761,9 @@ def package_reaction(request, package_uuid, reaction_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_reaction.name}" + context["title"] = ( + f"enviPath - {current_package.get_name()} - {current_reaction.get_name()}" + ) context["meta"]["current_package"] = current_package context["object_type"] = "reaction" @@ -1824,7 +1846,7 @@ def package_pathways(request, package_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Pathways" + context["title"] = f"enviPath - {current_package.get_name()} - Pathways" context["meta"]["current_package"] = current_package context["object_type"] = "pathway" @@ -1846,7 +1868,7 @@ def package_pathways(request, package_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_pathway_qs if current_package.reviewed @@ -1953,7 +1975,7 @@ def package_pathway(request, package_uuid, pathway_uuid): ) if request.GET.get("download", False) == "true": - filename = f"{current_pathway.name.replace(' ', '_')}_{current_pathway.uuid}.csv" + filename = f"{current_pathway.get_name().replace(' ', '_')}_{current_pathway.uuid}.csv" csv_pw = current_pathway.to_csv() response = HttpResponse(csv_pw, content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="{filename}"' @@ -1973,7 +1995,7 @@ def package_pathway(request, package_uuid, pathway_uuid): current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk ) - filename = f"{current_pathway.name.replace(' ', '_')}_{current_pathway.uuid}.csv" + filename = f"{current_pathway.get_name().replace(' ', '_')}_{current_pathway.uuid}.csv" response = HttpResponse(res, content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="{filename}"' @@ -1996,7 +2018,7 @@ def package_pathway(request, package_uuid, pathway_uuid): ).get(uuid=pathway_uuid) context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_pathway.name}" + context["title"] = f"enviPath - {current_package.get_name()} - {current_pathway.get_name()}" context["meta"]["current_package"] = current_package context["object_type"] = "pathway" @@ -2008,9 +2030,9 @@ def package_pathway(request, package_uuid, pathway_uuid): context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Package": s.SERVER_URL + "/package"}, - {current_package.name: current_package.url}, + {current_package.get_name(): current_package.url}, {"Pathway": current_package.url + "/pathway"}, - {current_pathway.name: current_pathway.url}, + {current_pathway.get_name(): current_pathway.url}, ] return render(request, "objects/pathway.html", context) @@ -2097,16 +2119,18 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_pathway.name} - Nodes" + context["title"] = ( + f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - Nodes" + ) context["meta"]["current_package"] = current_package context["object_type"] = "node" context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Package": s.SERVER_URL + "/package"}, - {current_package.name: current_package.url}, + {current_package.get_name(): current_package.url}, {"Pathway": current_package.url + "/pathway"}, - {current_pathway.name: current_pathway.url}, + {current_pathway.get_name(): current_pathway.url}, {"Node": current_pathway.url + "/node"}, ] @@ -2122,7 +2146,7 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_node_qs if current_package.reviewed else unreviewed_node_qs ) @@ -2196,7 +2220,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid): return HttpResponse(svg_data, content_type="image/svg+xml") context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_pathway.name}" + context["title"] = f"enviPath - {current_package.get_name()} - {current_pathway.get_name()}" context["meta"]["current_package"] = current_package context["object_type"] = "pathway" @@ -2204,11 +2228,11 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid): context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Package": s.SERVER_URL + "/package"}, - {current_package.name: current_package.url}, + {current_package.get_name(): current_package.url}, {"Pathway": current_package.url + "/pathway"}, - {current_pathway.name: current_pathway.url}, + {current_pathway.get_name(): current_pathway.url}, {"Node": current_pathway.url + "/node"}, - {current_node.name: current_node.url}, + {current_node.get_name(): current_node.url}, ] context["node"] = current_node @@ -2261,16 +2285,18 @@ def package_pathway_edges(request, package_uuid, pathway_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_pathway.name} - Edges" + context["title"] = ( + f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - Edges" + ) context["meta"]["current_package"] = current_package context["object_type"] = "edge" context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Package": s.SERVER_URL + "/package"}, - {current_package.name: current_package.url}, + {current_package.get_name(): current_package.url}, {"Pathway": current_package.url + "/pathway"}, - {current_pathway.name: current_pathway.url}, + {current_pathway.get_name(): current_pathway.url}, {"Edge": current_pathway.url + "/edge"}, ] @@ -2286,7 +2312,7 @@ def package_pathway_edges(request, package_uuid, pathway_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_edge_qs if current_package.reviewed else unreviewed_edge_qs ) @@ -2338,7 +2364,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid): context = get_base_context(request) context["title"] = ( - f"enviPath - {current_package.name} - {current_pathway.name} - {current_edge.edge_label.name}" + f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - {current_edge.edge_label.get_name()}" ) context["meta"]["current_package"] = current_package @@ -2391,11 +2417,11 @@ def package_scenarios(request, package_uuid): "all", False ): scens = Scenario.objects.filter(package=current_package).order_by("name") - res = [{"name": s_.name, "url": s_.url, "uuid": s_.uuid} for s_ in scens] + res = [{"name": s_.get_name(), "url": s_.url, "uuid": s_.uuid} for s_ in scens] return JsonResponse(res, safe=False) context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - Scenarios" + context["title"] = f"enviPath - {current_package.get_name()} - Scenarios" context["meta"]["current_package"] = current_package context["object_type"] = "scenario" @@ -2419,7 +2445,7 @@ def package_scenarios(request, package_uuid): return JsonResponse( { "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + {"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed} for pw in ( reviewed_scenario_qs if current_package.reviewed @@ -2511,7 +2537,9 @@ def package_scenario(request, package_uuid, scenario_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_scenario.name}" + context["title"] = ( + f"enviPath - {current_package.get_name()} - {current_scenario.get_name()}" + ) context["meta"]["current_package"] = current_package context["object_type"] = "scenario" @@ -2748,13 +2776,13 @@ def group(request, group_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_group.name}" + context["title"] = f"enviPath - {current_group.get_name()}" context["object_type"] = "group" context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Group": s.SERVER_URL + "/group"}, - {current_group.name: current_group.url}, + {current_group.get_name(): current_group.url}, ] context["group"] = current_group @@ -2909,13 +2937,13 @@ def setting(request, setting_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_setting.name}" + context["title"] = f"enviPath - {current_setting.get_name()}" context["object_type"] = "setting" context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Setting": s.SERVER_URL + "/setting"}, - {f"{current_setting.name}": current_setting.url}, + {f"{current_setting.get_name()}": current_setting.url}, ] context["setting"] = current_setting @@ -2964,8 +2992,8 @@ def jobs(request): target_package = PackageManager.create_package( current_user, - f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.name}", - f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.name}.", + f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.get_name()}", + f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.get_name()}.", ) from .tasks import dispatch, engineer_pathways @@ -3019,7 +3047,7 @@ def jobs(request): "This Package was generated automatically for the batch prediction task.", ) - from .tasks import dispatch, batch_predict + from .tasks import batch_predict, dispatch res = dispatch( current_user, @@ -3057,6 +3085,8 @@ def job(request, job_uuid): if job.job_name == "batch_predict": filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv" + elif job.job_name == "identify_missing_rules": + filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv" else: raise BadRequest("Result is not downloadable!") diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..bff00c56 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - '@parcel/watcher' + - '@tailwindcss/oxide' diff --git a/static/js/pw.js b/static/js/pw.js index 7320c05e..c06e8668 100644 --- a/static/js/pw.js +++ b/static/js/pw.js @@ -393,7 +393,14 @@ function draw(pathway, elem) { } function edge_popup(e) { - popupContent = "" + e.name + "
"; + popupContent = "" + e.name + "

"; + + if (e.reaction.rules) { + console.log(e.reaction.rules); + for (var rule of e.reaction.rules) { + popupContent += "Rule " + rule.name + "
"; + } + } if (e.app_domain) { adcontent = "

"; diff --git a/templates/index/index.html b/templates/index/index.html index 7e83ea3e..0f3e996e 100644 --- a/templates/index/index.html +++ b/templates/index/index.html @@ -176,8 +176,8 @@ href="#" class="example-link hover:text-primary cursor-pointer" title="load example" - @click.prevent="loadExample('CC(C)CC1=CC=C(C=C1)C(C)C(=O)O', $el)" - >IbuprofenAspartame {% endif %} + {% if compound_structure.half_lifes %} +

+ {% endif %} + {% if compound_structure.scenarios.all %}
diff --git a/templates/objects/edge.html b/templates/objects/edge.html index 21a1ecbc..f9d93d04 100644 --- a/templates/objects/edge.html +++ b/templates/objects/edge.html @@ -14,7 +14,7 @@
diff --git a/templates/objects/joblog.html b/templates/objects/joblog.html index b4dcf7f5..c313cbe2 100644 --- a/templates/objects/joblog.html +++ b/templates/objects/joblog.html @@ -103,7 +103,7 @@
- {% elif job.job_name == 'batch_predict' %} + {% elif job.job_name == 'batch_predict' or job.job_name == 'identify_missing_rules' %}
-

{{ node.name }}

+

{{ node.get_name }}