[Fix] UI bugs, Registrations Mail, BTRules Popup, Legacy API fixes (#309)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#309
This commit is contained in:
2026-01-29 11:13:34 +13:00
parent ab0b5a5186
commit 5565b9cb9e
14 changed files with 391 additions and 154 deletions

View File

@ -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