From df896878f1752e372e93ea6027025c1f2192fd74 Mon Sep 17 00:00:00 2001 From: jebus Date: Wed, 23 Jul 2025 06:47:07 +1200 Subject: [PATCH] Basic System (#31) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/31 --- envipath/settings.py | 32 +- epdb/logic.py | 249 ++++++++- epdb/management/commands/bootstrap.py | 17 +- epdb/models.py | 303 ++++++++--- epdb/tasks.py | 43 +- epdb/templatetags/__init__.py | 0 epdb/templatetags/envipytags.py | 7 + epdb/urls.py | 8 +- epdb/views.py | 450 +++++++++++++--- static/images/enviPy-screenshot.png | Bin 0 -> 92757 bytes static/js/pps.js | 22 - static/js/pw.js | 153 +++++- templates/actions/collections/compound.html | 10 +- .../collections/compound_structure.html | 10 +- templates/actions/collections/edge.html | 6 + templates/actions/collections/group.html | 2 +- templates/actions/collections/model.html | 10 +- templates/actions/collections/node.html | 6 + templates/actions/collections/pathway.html | 10 +- templates/actions/collections/reaction.html | 10 +- templates/actions/collections/rule.html | 10 +- templates/actions/collections/scenario.html | 10 +- templates/actions/collections/setting.html | 10 +- templates/actions/objects/compound.html | 26 +- .../actions/objects/compound_structure.html | 18 +- templates/actions/objects/group.html | 18 +- templates/actions/objects/model.html | 6 + templates/actions/objects/node.html | 10 + templates/actions/objects/package.html | 34 +- templates/actions/objects/pathway.html | 40 +- templates/actions/objects/reaction.html | 18 +- templates/actions/objects/rule.html | 10 +- templates/actions/objects/scenario.html | 2 + templates/actions/objects/user.html | 42 +- templates/collections/objects_list.html | 155 +++--- templates/errors/error.html | 14 + templates/errors/user_account_inactive.html | 4 +- templates/framework.html | 179 +++--- templates/index/index.html | 257 +++++---- templates/login.html | 189 +++++++ .../modals/collections/new_edge_modal.html | 0 .../modals/collections/new_node_modal.html | 0 .../modals/collections/new_rule_modal.html | 49 +- .../objects/add_pathway_edge_modal.html | 126 +++++ .../objects/add_pathway_node_modal.html | 78 +++ .../modals/objects/delete_model_modal.html | 35 ++ .../modals/objects/delete_node_modal.html | 35 ++ .../objects/delete_pathway_edge_modal.html | 65 +++ .../modals/objects/delete_pathway_modal.html | 35 ++ .../objects/delete_pathway_node_modal.html | 65 +++ .../modals/objects/delete_reaction_modal.html | 35 ++ .../modals/objects/edit_compound_modal.html | 4 +- templates/modals/objects/edit_node_modal.html | 46 ++ .../modals/objects/edit_pathway_modal.html | 43 ++ .../modals/objects/edit_reaction_modal.html | 1 - templates/modals/objects/edit_rule_modal.html | 44 ++ .../objects/manage_api_token_modal.html | 1 - templates/objects/composite_rule.html | 2 +- templates/objects/compound.html | 242 +++++---- templates/objects/compound_structure.html | 2 +- templates/objects/edge.html | 0 templates/objects/group.html | 2 +- templates/objects/model.html | 509 ++++++++++-------- templates/objects/node.html | 75 +++ templates/objects/package.html | 2 +- templates/objects/pathway.html | 265 +++++---- templates/objects/reaction.html | 199 ++++--- templates/objects/scenario.html | 2 +- templates/objects/simple_rule.html | 317 +++++------ templates/objects/user.html | 2 +- templates/search.html | 239 +++++--- tests/test_compound_model.py | 10 +- tests/test_reaction_model.py | 196 +++++++ tests/test_rule_model.py | 116 ++++ utilities/chem.py | 8 + 75 files changed, 3821 insertions(+), 1429 deletions(-) create mode 100644 epdb/templatetags/__init__.py create mode 100644 epdb/templatetags/envipytags.py create mode 100644 static/images/enviPy-screenshot.png create mode 100644 templates/actions/collections/edge.html create mode 100644 templates/actions/collections/node.html create mode 100644 templates/actions/objects/model.html create mode 100644 templates/actions/objects/node.html create mode 100644 templates/errors/error.html create mode 100644 templates/login.html create mode 100644 templates/modals/collections/new_edge_modal.html create mode 100644 templates/modals/collections/new_node_modal.html create mode 100644 templates/modals/objects/add_pathway_edge_modal.html create mode 100644 templates/modals/objects/add_pathway_node_modal.html create mode 100644 templates/modals/objects/delete_model_modal.html create mode 100644 templates/modals/objects/delete_node_modal.html create mode 100644 templates/modals/objects/delete_pathway_edge_modal.html create mode 100644 templates/modals/objects/delete_pathway_modal.html create mode 100644 templates/modals/objects/delete_pathway_node_modal.html create mode 100644 templates/modals/objects/delete_reaction_modal.html create mode 100644 templates/modals/objects/edit_node_modal.html create mode 100644 templates/modals/objects/edit_pathway_modal.html create mode 100644 templates/objects/edge.html create mode 100644 templates/objects/node.html create mode 100644 tests/test_reaction_model.py create mode 100644 tests/test_rule_model.py diff --git a/envipath/settings.py b/envipath/settings.py index ebdd3622..2ca661de 100644 --- a/envipath/settings.py +++ b/envipath/settings.py @@ -15,6 +15,7 @@ from pathlib import Path from dotenv import load_dotenv from envipy_plugins import Classifier, Property, Descriptor from sklearn.ensemble import RandomForestClassifier +from sklearn.tree import DecisionTreeClassifier # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -256,14 +257,29 @@ CELERY_RESULT_BACKEND = 'redis://localhost:6379/1' CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' -DEFAULT_MODELS_PARAMS = { - 'base_clf': RandomForestClassifier(n_estimators=100, - max_features='log2', - random_state=42, - criterion='entropy', - ccp_alpha=0.0, - max_depth=3, - min_samples_leaf=1), +DEFAULT_RF_MODEL_PARAMS = { + 'base_clf': RandomForestClassifier( + n_estimators=100, + max_features='log2', + random_state=42, + criterion='entropy', + ccp_alpha=0.0, + max_depth=3, + min_samples_leaf=1 + ), + 'num_chains': 10, +} + +DEFAULT_DT_MODEL_PARAMS = { + 'base_clf': DecisionTreeClassifier( + criterion='entropy', + max_depth=3, + min_samples_split=5, + min_samples_leaf=5, + max_features='sqrt', + class_weight='balanced', + random_state=42 + ), 'num_chains': 10, } diff --git a/epdb/logic.py b/epdb/logic.py index c4c541e6..6d9397ac 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -7,13 +7,21 @@ from django.db import transaction from django.conf import settings as s from epdb.models import User, Package, UserPackagePermission, GroupPackagePermission, Permission, Group, Setting, \ - EPModel, UserSettingPermission, Rule, Pathway, Node, Edge + EPModel, UserSettingPermission, Rule, Pathway, Node, Edge, Compound, Reaction, CompoundStructure +from utilities.chem import FormatConverter logger = logging.getLogger(__name__) + class UserManager(object): + user_pattern = re.compile(r".*/user/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") + @staticmethod - def create_user(username, email, password, *args, **kwargs): + def is_user_url(url: str): + return bool(re.findall(UserManager.user_pattern, url)) + + @staticmethod + def create_user(username, email, password, set_setting=True, add_to_group=True, *args, **kwargs): # avoid circular import :S from .tasks import send_registration_mail @@ -34,6 +42,17 @@ class UserManager(object): # send email for verification send_registration_mail.delay(u.pk) + if set_setting: + u.default_setting = Setting.objects.get(global_default=True) + u.save() + + if add_to_group: + g = Group.objects.get(public=True, name='enviPath Users') + g.user_member.add(u) + g.save() + u.default_group = g + u.save() + return u @staticmethod @@ -54,7 +73,16 @@ class UserManager(object): uuid = user_url.strip().split('/')[-1] return get_user_model().objects.get(uuid=uuid) + @staticmethod + def writable(current_user, user): + return (current_user == user) or user.is_superuser + class GroupManager(object): + group_pattern = re.compile(r".*/group/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") + + @staticmethod + def is_group_url(url: str): + return bool(re.findall(GroupManager.group_pattern, url)) @staticmethod def create_group(current_user, name, description): @@ -110,9 +138,17 @@ class GroupManager(object): group.save() + @staticmethod + def writable(user, group): + return (user == group.owner) or user.is_superuser + class PackageManager(object): - package_pattern = re.compile(r".*/package/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$") + package_pattern = re.compile(r".*/package/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") + + @staticmethod + def is_package_url(url: str): + return bool(re.findall(PackageManager.package_pattern, url)) @staticmethod def get_reviewed_packages(): @@ -120,7 +156,6 @@ class PackageManager(object): @staticmethod def readable(user, package): - # TODO Owner! if UserPackagePermission.objects.filter(package=package, user=user).exists() or \ GroupPackagePermission.objects.filter(package=package, group__in=GroupManager.get_groups(user)) or \ package.reviewed is True or \ @@ -131,14 +166,21 @@ class PackageManager(object): @staticmethod def writable(user, package): - # TODO Owner! - if UserPackagePermission.objects.filter(package=package, user=user, permission=Permission.WRITE).exists() or \ - GroupPackagePermission.objects.filter(package=package, group__in=GroupManager.get_groups(user), - permission=Permission.WRITE) or \ + if UserPackagePermission.objects.filter(package=package, user=user, permission=Permission.WRITE[0]).exists() or \ + GroupPackagePermission.objects.filter(package=package, group__in=GroupManager.get_groups(user), permission=Permission.WRITE[0]).exists() or \ + UserPackagePermission.objects.filter(package=package, user=user, permission=Permission.ALL[0]).exists() or \ user.is_superuser: return True return False + @staticmethod + def get_package_lp(package_url): + match = re.findall(PackageManager.package_pattern, package_url) + if match: + package_id = match[0].split('/')[-1] + return Package.objects.get(uuid=package_id) + return None + @staticmethod def get_package_by_url(user, package_url): match = re.findall(PackageManager.package_pattern, package_url) @@ -229,8 +271,12 @@ class PackageManager(object): @staticmethod @transaction.atomic def update_permissions(caller: User, package: Package, grantee: Union[User, Group], new_perm: Optional[str]): - if not PackageManager.writable(caller, package): - raise ValueError(f"User {caller} is not allowed to modify permissions on {package}") + caller_perm = None + if not caller.is_superuser: + caller_perm = UserPackagePermission.objects.get(user=caller, package=package).permission + + if caller_perm != Permission.ALL[0] and not caller.is_superuser: + raise ValueError(f"Only owner are allowed to modify permissions") data = { 'package': package, @@ -629,8 +675,6 @@ class PackageManager(object): return pack - - class SettingManager(object): setting_pattern = re.compile(r".*/setting/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$") @@ -648,7 +692,8 @@ class SettingManager(object): def get_setting_by_id(user, setting_id): s = Setting.objects.get(uuid=setting_id) - if s.global_default or s.public or s.owner == user or user.is_superuser: + if s.global_default or s.public or user.is_superuser or \ + UserSettingPermission.objects.filter(user=user, setting=s).exists(): return s raise ValueError( @@ -697,6 +742,116 @@ class SettingManager(object): def set_default_setting(user: User, setting: Setting): pass +class SearchManager(object): + + + @staticmethod + def search(packages: Union[Package, List[Package]], searchterm: str, mode: str): + match mode: + case 'text': + return SearchManager._search_text(packages, searchterm) + case 'default': + return SearchManager._search_default_smiles(packages, searchterm) + case 'exact': + return SearchManager._search_exact_smiles(packages, searchterm) + case 'canonical': + return SearchManager._search_canonical_smiles(packages, searchterm) + case 'inchikey': + return SearchManager._search_inchikey(packages, searchterm) + case _: + raise ValueError(f"Unknown search mode {mode}!") + + @staticmethod + def _search_inchikey(packages: Union[Package, List[Package]], searchterm: str): + from django.db.models import Q + + search_cond = Q(inchikey=searchterm) + compound_qs = Compound.objects.filter(Q(package__in=packages) & Q(compoundstructure__inchikey=searchterm)).distinct() + compound_structure_qs = CompoundStructure.objects.filter(Q(compound__package__in=packages) & search_cond) + reactions_qs = Reaction.objects.filter(Q(package__in=packages) & (Q(educts__inchikey=searchterm) | Q(products__inchikey=searchterm))).distinct() + pathway_qs = Pathway.objects.filter(Q(package__in=packages) & (Q(edge__edge_label__educts__inchikey=searchterm) | Q(edge__edge_label__products__inchikey=searchterm))).distinct() + + return { + 'Compounds': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_qs], + 'Compound Structures': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_structure_qs], + 'Reactions': [{'name': r.name, 'description': r.description, 'url': r.url} for r in reactions_qs], + 'Pathways': [{'name': p.name, 'description': p.description, 'url': p.url} for p in pathway_qs], + } + + @staticmethod + def _search_exact_smiles(packages: Union[Package, List[Package]], searchterm: str): + from django.db.models import Q + + search_cond = Q(smiles=searchterm) + compound_qs = Compound.objects.filter(Q(package__in=packages) & Q(compoundstructure__smiles=searchterm)).distinct() + compound_structure_qs = CompoundStructure.objects.filter(Q(compound__package__in=packages) & search_cond) + reactions_qs = Reaction.objects.filter(Q(package__in=packages) & (Q(educts__smiles=searchterm) | Q(products__smiles=searchterm))).distinct() + pathway_qs = Pathway.objects.filter(Q(package__in=packages) & (Q(edge__edge_label__educts__smiles=searchterm) | Q(edge__edge_label__products__smiles=searchterm))).distinct() + + return { + 'Compounds': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_qs], + 'Compound Structures': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_structure_qs], + 'Reactions': [{'name': r.name, 'description': r.description, 'url': r.url} for r in reactions_qs], + 'Pathways': [{'name': p.name, 'description': p.description, 'url': p.url} for p in pathway_qs], + } + + @staticmethod + def _search_default_smiles(packages: Union[Package, List[Package]], searchterm: str): + from django.db.models import Q + + inchi_front = FormatConverter.InChIKey(searchterm)[:14] + + search_cond = Q(inchikey__startswith=inchi_front) + compound_qs = Compound.objects.filter(Q(package__in=packages) & Q(compoundstructure__inchikey__startswith=inchi_front)).distinct() + compound_structure_qs = CompoundStructure.objects.filter(Q(compound__package__in=packages) & search_cond) + reactions_qs = Reaction.objects.filter(Q(package__in=packages) & (Q(educts__inchikey__startswith=inchi_front) | Q(products__inchikey__startswith=inchi_front))).distinct() + pathway_qs = Pathway.objects.filter(Q(package__in=packages) & (Q(edge__edge_label__educts__inchikey__startswith=inchi_front) | Q(edge__edge_label__products__inchikey__startswith=inchi_front))).distinct() + + return { + 'Compounds': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_qs], + 'Compound Structures': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_structure_qs], + 'Reactions': [{'name': r.name, 'description': r.description, 'url': r.url} for r in reactions_qs], + 'Pathways': [{'name': p.name, 'description': p.description, 'url': p.url} for p in pathway_qs], + } + + @staticmethod + def _search_canonical_smiles(packages: Union[Package, List[Package]], searchterm: str): + from django.db.models import Q + + search_cond = Q(canonical_smiles=searchterm) + compound_qs = Compound.objects.filter(Q(package__in=packages) & Q(compoundstructure__canonical_smiles=searchterm)).distinct() + compound_structure_qs = CompoundStructure.objects.filter(Q(compound__package__in=packages) & search_cond) + reactions_qs = Reaction.objects.filter(Q(package__in=packages) & (Q(educts__canonical_smiles=searchterm) | Q(products__canonical_smiles=searchterm))).distinct() + pathway_qs = Pathway.objects.filter(Q(package__in=packages) & (Q(edge__edge_label__educts__canonical_smiles=searchterm) | Q(edge__edge_label__products__canonical_smiles=searchterm))).distinct() + + return { + 'Compounds': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_qs], + 'Compound Structures': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_structure_qs], + 'Reactions': [{'name': r.name, 'description': r.description, 'url': r.url} for r in reactions_qs], + 'Pathways': [{'name': p.name, 'description': p.description, 'url': p.url} for p in pathway_qs], + } + + @staticmethod + def _search_text(packages: Union[Package, List[Package]], searchterm: str): + from django.db.models import Q + + search_cond = (Q(name__icontains=searchterm) | Q(description__icontains=searchterm)) + cond = Q(package__in=packages) & search_cond + compound_qs = Compound.objects.filter(cond) + compound_structure_qs = CompoundStructure.objects.filter(Q(compound__package__in=packages) & search_cond) + rule_qs = Rule.objects.filter(cond) + reactions_qs = Reaction.objects.filter(cond) + pathway_qs = Pathway.objects.filter(cond) + + res = { + 'Compounds': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_qs], + 'Compound Structures': [{'name': c.name, 'description': c.description, 'url': c.url} for c in compound_structure_qs], + 'Rules': [{'name': r.name, 'description': r.description, 'url': r.url} for r in rule_qs], + 'Reactions': [{'name': r.name, 'description': r.description, 'url': r.url} for r in reactions_qs], + 'Pathways': [{'name': p.name, 'description': p.description, 'url': p.url} for p in pathway_qs], + } + return res + class SNode(object): @@ -719,7 +874,7 @@ class SNode(object): class SEdge(object): def __init__(self, educts: Union[SNode, List[SNode]], products: Union[SNode | List[SNode]], - rule: Optional['Rule'] = None): + rule: Optional['Rule'] = None, probability: Optional[float] = None): if not isinstance(educts, list): educts = [educts] @@ -727,6 +882,7 @@ class SEdge(object): self.educts = educts self.products = products self.rule = rule + self.probability = probability def __hash__(self): full_hash = 0 @@ -799,11 +955,45 @@ class SPathway(object): elif isinstance(n, SNode): self.root_nodes.append(n) - self.queue = list() self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes}) self.edges: Set['SEdge'] = set() self.done = False + @staticmethod + def from_pathway(pw: 'Pathway', persist: bool = True): + """ Initializes a SPathway with a state given by a Pathway """ + spw = SPathway(root_nodes=pw.root_nodes, persist=pw if persist else None, prediction_setting=pw.setting) + # root_nodes are already added in __init__, add remaining nodes + for n in pw.nodes: + snode = SNode(n.default_node_label.smiles, n.depth) + if snode.smiles not in spw.smiles_to_node: + spw.smiles_to_node[snode.smiles] = snode + spw.snode_persist_lookup[snode] = n + + for e in pw.edges: + sub = [] + prod = [] + + for n in e.start_nodes.all(): + sub.append(spw.smiles_to_node[n.default_node_label.smiles]) + + for n in e.end_nodes.all(): + prod.append(spw.smiles_to_node[n.default_node_label.smiles]) + + rule = None + if e.edge_label.rules.all(): + rule = e.edge_label.rules.all().first() + + prob = None + if e.kv.get('probability'): + prob = float(e.kv['probability']) + + sedge = SEdge(sub, prod, rule=rule, probability=prob) + spw.edges.add(sedge) + spw.sedge_persist_lookup[sedge] = e + + return spw + def num_nodes(self): return len(self.smiles_to_node.keys()) @@ -830,8 +1020,18 @@ class SPathway(object): return sorted(res, key=lambda x: hash(x)) - def predict_step(self, from_depth: int = 0): - substrates = self._get_nodes_for_depth(from_depth) + def predict_step(self, from_depth: int = None, from_node: 'Node' = None): + substrates: List[SNode] = [] + + if from_depth is not None: + substrates = self._get_nodes_for_depth(from_depth) + elif from_node is not None: + for k,v in self.snode_persist_lookup.items(): + if from_node == v: + substrates = [k] + break + else: + raise ValueError("Neither from_depth nor from_node_url specified") new_tp = False if substrates: @@ -849,13 +1049,19 @@ class SPathway(object): node = self.smiles_to_node[c] cand_nodes.append(node) - edge = SEdge(sub, cand_nodes, cand_set.rule) + edge = SEdge(sub, cand_nodes, rule=cand_set.rule, probability=cand_set.probability) self.edges.add(edge) - else: + + # In case no substrates are found, we're done. + # For "predict from node" we're always done + if len(substrates) == 0 or from_node is not None: self.done = True + # Check if we need to write back data to database if new_tp and self.persist: self._sync_to_pathway() + # call save to update internal modified field + self.persist.save() def _sync_to_pathway(self): logger.info("Updating Pathway with SPathway") @@ -876,6 +1082,11 @@ class SPathway(object): product_nodes.append(self.snode_persist_lookup[snode]) e = Edge.create(self.persist, educt_nodes, product_nodes, sedge.rule) + + if sedge.probability: + e.kv.update({'probability': sedge.probability}) + e.save() + self.sedge_persist_lookup[sedge] = e logger.info("Update done!") diff --git a/epdb/management/commands/bootstrap.py b/epdb/management/commands/bootstrap.py index b28e6620..6ca3e18e 100644 --- a/epdb/management/commands/bootstrap.py +++ b/epdb/management/commands/bootstrap.py @@ -13,12 +13,14 @@ class Command(BaseCommand): def create_users(self): if not User.objects.filter(email='anon@lorsba.ch').exists(): - anon = UserManager.create_user("anonymous", "anon@lorsba.ch", "SuperSafe", is_active=True) + anon = UserManager.create_user("anonymous", "anon@lorsba.ch", "SuperSafe", is_active=True, + add_to_group=False, set_setting=False) else: anon = User.objects.get(email='anon@lorsba.ch') if not User.objects.filter(email='admin@lorsba.ch').exists(): - admin = UserManager.create_user("admin", "admin@lorsba.ch", "SuperSafe", is_active=True) + admin = UserManager.create_user("admin", "admin@lorsba.ch", "SuperSafe", is_active=True, add_to_group=False, + set_setting=False) admin.is_staff = True admin.is_superuser = True admin.save() @@ -26,6 +28,9 @@ class Command(BaseCommand): admin = User.objects.get(email='admin@lorsba.ch') g = GroupManager.create_group(admin, 'enviPath Users', 'All enviPath Users') + g.public = True + g.save() + g.user_member.add(anon) g.save() @@ -36,7 +41,8 @@ class Command(BaseCommand): admin.save() if not User.objects.filter(email='jebus@lorsba.ch').exists(): - jebus = UserManager.create_user("jebus", "jebus@lorsba.ch", "SuperSafe", is_active=True) + jebus = UserManager.create_user("jebus", "jebus@lorsba.ch", "SuperSafe", is_active=True, add_to_group=False, + set_setting=False) jebus.is_staff = True jebus.is_superuser = True jebus.save() @@ -94,6 +100,9 @@ class Command(BaseCommand): setting.make_global_default() for u in [anon, jebus]: + u.default_setting = setting + u.save() + usp = UserSettingPermission() usp.user = u usp.setting = setting @@ -119,7 +128,7 @@ class Command(BaseCommand): X, y = ml_model.build_dataset() ml_model.build_model(X, y) - ml_model.evaluate_model() + # ml_model.evaluate_model() # If available create EnviFormerModel if s.ENVIFORMER_PRESENT: diff --git a/epdb/models.py b/epdb/models.py index c19de2ba..41546ff7 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -14,7 +14,7 @@ from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import ArrayField from django.db import models, transaction -from django.db.models import JSONField +from django.db.models import JSONField, Count, Q from django.utils import timezone from django.utils.functional import cached_property from model_utils.models import TimeStampedModel @@ -43,8 +43,6 @@ class User(AbstractUser): on_delete=models.SET_NULL, related_name='default_group') default_setting = models.ForeignKey('epdb.Setting', on_delete=models.SET_NULL, verbose_name='The users default settings', null=True, blank=False) - # TODO remove - groups = models.ManyToManyField("Group", verbose_name='groups') USERNAME_FIELD = "email" REQUIRED_FIELDS = ['username'] @@ -87,6 +85,7 @@ class Group(TimeStampedModel): uuid = models.UUIDField(null=False, blank=False, verbose_name='UUID of this object', unique=True, default=uuid4) name = models.TextField(blank=False, null=False, verbose_name='Group name') owner = models.ForeignKey("User", verbose_name='Group Owner', on_delete=models.CASCADE) + public = models.BooleanField(verbose_name='Public Group', default=False) description = models.TextField(blank=False, null=False, verbose_name='Descriptions', default='no description') user_member = models.ManyToManyField("User", verbose_name='User members', related_name='users_in_group') group_member = models.ManyToManyField("Group", verbose_name='Group member', related_name='groups_in_group') @@ -314,7 +313,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin): @transaction.atomic def create(package: Package, smiles: str, name: str = None, description: str = None, *args, **kwargs) -> 'Compound': - if smiles is None or smiles == '': + if smiles is None or smiles.strip() == '': raise ValueError('SMILES is required') smiles = smiles.strip() @@ -338,12 +337,14 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin): c = Compound() c.package = package - # For name and description we have defaults so only set them if they carry a real value - if name is not None and name != '': - c.name = name + if name is None or name.strip() == '': + name = f"Compound {Compound.objects.filter(package=package).count() + 1}" - if description is not None and description != '': - c.description = description + c.name = name + + # We have a default here only set the value if it carries some payload + if description is not None and description.strip() != '': + c.description = description.strip() c.save() @@ -403,25 +404,27 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin): class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin): compound = models.ForeignKey('epdb.Compound', on_delete=models.CASCADE, db_index=True) smiles = models.TextField(blank=False, null=False, verbose_name='SMILES') + canonical_smiles = models.TextField(blank=False, null=False, verbose_name='Canonical SMILES') + inchikey = models.TextField(max_length=27, blank=False, null=False, verbose_name="InChIKey") normalized_structure = models.BooleanField(null=False, blank=False, default=False) + def save(self, *args, **kwargs): + # Compute these fields only on initial save call + if self.pk is None: + try: + # Generate canonical SMILES + self.canonical_smiles = FormatConverter.canonicalize(self.smiles) + # Generate InChIKey + self.inchikey = FormatConverter.InChIKey(self.smiles) + except Exception as e: + logger.error(f"Could compute canonical SMILES/InChIKey from {self.smiles}, error: {e}") + + super().save(*args, **kwargs) + @property def url(self): return '{}/structure/{}'.format(self.compound.url, self.uuid) - # @property - # def related_pathways(self): - # pathways = Node.objects.filter(node_labels__in=[self]).values_list('pathway', flat=True) - # return Pathway.objects.filter(package=self.compound.package, id__in=set(pathways)).order_by('name') - - # @property - # def related_reactions(self): - # return ( - # Reaction.objects.filter(package=self.compound.package, educts__in=[self]) - # | - # Reaction.objects.filter(package=self.compound.package, products__in=[self]) - # ).order_by('name') - @staticmethod @transaction.atomic def create(compound: Compound, smiles: str, name: str = None, description: str = None, *args, **kwargs): @@ -448,19 +451,9 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin): return cs - # TODO add find method - @property - def InChIKey(self): - return FormatConverter.InChIKey(self.smiles) - - @property - def canonical_smiles(self): - return FormatConverter.canonicalize(self.smiles) - - @property - def as_svg(self): - return IndigoUtils.mol_to_svg(self.smiles) + def as_svg(self, width: int = 800, height: int = 400): + return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height) class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): @@ -492,19 +485,9 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): @staticmethod @transaction.atomic - def create(package: Package, rule_type: str, name: str = None, description: str = None, *args, **kwargs): - r = Rule.cls_for_type(rule_type)() - r.package = package - r.name = name - r.description = description - - # As we are setting params this way the "k" has to match the property name - for k, v in kwargs.items(): - setattr(r, k, v) - - r.save() - return r - + def create(rule_type: str, *args, **kwargs): + cls = Rule.cls_for_type(rule_type) + return cls.create(*args, **kwargs) # # @property @@ -533,6 +516,54 @@ class SimpleAmbitRule(SimpleRule): reactant_filter_smarts = models.TextField(null=True, verbose_name='Reactant Filter SMARTS') product_filter_smarts = models.TextField(null=True, verbose_name='Product Filter SMARTS') + @staticmethod + @transaction.atomic + def create(package: Package, name: str = None, description: str = None, smirks: str = None, + reactant_filter_smarts: str = None, product_filter_smarts: str = None): + + if smirks is None or smirks.strip() == '': + raise ValueError('SMIRKS is required!') + + smirks = smirks.strip() + + if not FormatConverter.is_valid_smirks(smirks): + raise ValueError(f'SMIRKS "{smirks}" is invalid!') + + query = SimpleAmbitRule.objects.filter(package=package, smirks=smirks) + + if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != '': + query = query.filter(reactant_filter_smarts=reactant_filter_smarts) + + if product_filter_smarts is not None and product_filter_smarts.strip() != '': + query = query.filter(product_filter_smarts=product_filter_smarts) + + if query.exists(): + if query.count() > 1: + logger.error(f'More than one rule matched this one! {query}') + return query.first() + + r = SimpleAmbitRule() + r.package = package + + if name is None or name.strip() == '': + name = f'Rule {Rule.objects.filter(package=package).count() + 1}' + + r.name = name + + if description is not None and description.strip() != '': + r.description = description + + r.smirks = smirks + + if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != '': + r.reactant_filter_smarts = reactant_filter_smarts + + if product_filter_smarts is not None and product_filter_smarts.strip() != '': + r.product_filter_smarts = product_filter_smarts + + r.save() + return r + @property def url(self): return '{}/simple-ambit-rule/{}'.format(self.package.url, self.uuid) @@ -642,7 +673,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin): def create(package: Package, name: str = None, description: str = None, educts: Union[List[str], List[CompoundStructure]] = None, products: Union[List[str], List[CompoundStructure]] = None, - rule: Rule = None, multi_step: bool = True): + rules: Union[Rule|List[Rule]] = None, multi_step: bool = True): _educts = [] _products = [] @@ -662,18 +693,61 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin): _products += products else: - raise ValueError("") + raise ValueError("Found mixed types for educts and/or products!") + + if len(_educts) == 0 or len(_products) == 0: + raise ValueError("No educts or products specified!") + + if rules is None: + rules = [] + + if isinstance(rules, Rule): + rules = [rules] + + + query = Reaction.objects.annotate( + educt_count=Count('educts', filter=Q(educts__in=_educts), distinct=True), + product_count=Count('products', filter=Q(products__in=_products), distinct=True), + ) + + # The annotate/filter wont work if rules is an empty list + if rules: + query = query.annotate( + rule_count=Count('rules', filter=Q(rules__in=rules), distinct=True) + ).filter(rule_count=len(rules)) + else: + query = query.annotate( + rule_count=Count('rules', distinct=True) + ).filter(rule_count=0) + + existing_reaction_qs = query.filter( + educt_count=len(_educts), + product_count=len(_products), + multi_step=multi_step, + package=package + ) + + if existing_reaction_qs.exists(): + if existing_reaction_qs.count() > 1: + logger.error(f'Found more than one reaction for given input! {existing_reaction_qs}') + return existing_reaction_qs.first() r = Reaction() r.package = package - r.name = name - r.description = description + + if name is not None and name.strip() != '': + r.name = name + + if description is not None and name.strip() != '': + r.description = description + r.multi_step = multi_step r.save() - if rule: - r.rules.add(rule) + if rules: + for rule in rules: + r.rules.add(rule) for educt in _educts: r.educts.add(educt) @@ -700,6 +774,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin): class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): package = models.ForeignKey('epdb.Package', verbose_name='Package', on_delete=models.CASCADE, db_index=True) + setting = models.ForeignKey('epdb.Setting', verbose_name='Setting', on_delete=models.CASCADE, null=True, blank=True) @property def root_nodes(self): @@ -709,6 +784,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): def nodes(self): return Node.objects.filter(pathway=self) + def get_node(self, node_url): + for n in self.nodes: + if n.url == node_url: + return n + return None + @property def edges(self): return Edge.objects.filter(pathway=self) @@ -717,6 +798,26 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): def url(self): return '{}/pathway/{}'.format(self.package.url, self.uuid) + # Mode + def is_built(self): + return self.kv.get('mode', 'build') == 'predicted' + + def is_predicted(self): + return self.kv.get('mode', 'build') == 'predicted' + + def is_predicted(self): + return self.kv.get('mode', 'build') == 'predicted' + + # Status + def completed(self): + return self.kv.get('status', 'completed') == 'completed' + + def running(self): + return self.kv.get('status', 'completed') == 'running' + + def failed(self): + return self.kv.get('status', 'completed') == 'failed' + def d3_json(self): # Ideally it would be something like this but # to reduce crossing in edges do a DFS @@ -770,7 +871,11 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): new_link = { 'name': link['name'], 'id': link['id'], + 'url': link['url'], + 'image': link['image'], 'reaction': link['reaction'], + 'reaction_probability': link['reaction_probability'], + 'scenarios': link['scenarios'], 'source': node_url_to_idx[link['start_node_urls'][0]], 'target': pseudo_idx } @@ -781,7 +886,11 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): new_link = { 'name': link['name'], 'id': link['id'], + 'url': link['url'], + 'image': link['image'], 'reaction': link['reaction'], + 'reaction_probability': link['reaction_probability'], + 'scenarios': link['scenarios'], 'source': pseudo_idx, 'target': node_url_to_idx[target] } @@ -797,9 +906,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): "completed": "true", "description": self.description, "id": self.url, - "isIncremental": False, - "isPredicted": False, - "lastModified": 1447842835894, + "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, "reviewStatus": "reviewed" if self.package.reviewed else 'unreviewed', "scenarios": [], @@ -813,18 +922,38 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): @staticmethod @transaction.atomic - def create(package, name, description, smiles): + def create(package: 'Package', smiles: str, name: Optional[str] = None, description: Optional[str] = None): pw = Pathway() pw.package = package - pw.name = name - pw.description = description - pw.save() - # create root node - Node.create(pw, smiles, 0) + if name is None: + name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}" + + pw.name = name + + if description is not None: + pw.description = description + + pw.save() + try: + # create root node + Node.create(pw, smiles, 0) + except ValueError as e: + # Node creation failed, most likely due to an invalid smiles + # delete this pathway... + pw.delete() + raise e return pw + @transaction.atomic + def add_node(self, smiles: str, name: Optional[str] = None, description: Optional[str] = None): + return Node.create(self, smiles, 0) + + @transaction.atomic + def add_edge(self, start_nodes: List['Node'], end_nodes: List['Node'], rule: Optional['Rule'] = None, + name: Optional[str] = None, description: Optional[str] = None): + return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description) class Node(EnviPathModel, AliasMixin, ScenarioMixin): pathway = models.ForeignKey('epdb.Pathway', verbose_name='belongs to', on_delete=models.CASCADE, db_index=True) @@ -848,14 +977,14 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): "imageSize": 490, # TODO "name": self.default_node_label.name, "smiles": self.default_node_label.smiles, + "scenarios": [{'name': s.name, 'url': s.url} for s in self.scenarios.all()], } @staticmethod - def create(pathway, smiles, depth): - c = Compound.create(pathway.package, smiles) + def create(pathway: 'Pathway', smiles: str, depth: int, name: Optional[str] = None, description: Optional[str] = None): + c = Compound.create(pathway.package, smiles, name=name, description=description) if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists(): - print("found node") return Node.objects.get(pathway=pathway, default_node_label=c.default_structure) n = Node() @@ -886,34 +1015,21 @@ class Edge(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): return '{}/edge/{}'.format(self.pathway.url, self.uuid) def d3_json(self): - # { - # "ecNumbers": [ - # { - # "ecName": "DDT 2,3-dioxygenase", - # "ecNumber": "1.14.12.-" - # } - # ], - # "id": "https://envipath.org/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1/pathway/3f58e4d4-1c63-4b30-bf31-7ae4b98899fe/edge/ff193e7b-f010-43d4-acb3-45f34d938824", - # "idreaction": "https://envipath.org/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1/reaction/e11419cd-6b46-470b-8a06-a08d62281734", - # "multistep": "false", - # "name": "Eawag BBD reaction r0450", - # "pseudo": False, - # "scenarios": [], - # "source": 0, - # "target": 4 - # } - return { 'name': self.name, 'id': self.url, - 'reaction': self.edge_label.url if self.edge_label else None, + 'url': self.url, + 'image': self.url + '?image=svg', + 'reaction': {'name': self.edge_label.name, 'url': self.edge_label.url } if self.edge_label else None, + 'reaction_probability': self.kv.get('probability'), # TODO '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()], } @staticmethod - def create(pathway, start_nodes, end_nodes, rule: Optional[Rule] = None, name: Optional[str] = None, + def create(pathway, start_nodes: List[Node], end_nodes: List[Node], rule: Optional[Rule] = None, name: Optional[str] = None, description: Optional[str] = None): e = Edge() e.pathway = pathway @@ -934,13 +1050,17 @@ class Edge(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): r = Reaction.create(pathway.package, name=name, description=description, educts=[n.default_node_label for n in e.start_nodes.all()], products=[n.default_node_label for n in e.end_nodes.all()], - rule=rule, multi_step=False + rules=rule, multi_step=False ) e.edge_label = r e.save() return e + @property + def as_svg(self): + return self.edge_label.as_svg if self.edge_label else None + class EPModel(PolymorphicModel, EnviPathModel): package = models.ForeignKey('epdb.Package', verbose_name='Package', on_delete=models.CASCADE, db_index=True) @@ -976,6 +1096,9 @@ class MLRelativeReasoning(EPModel): eval_results = JSONField(null=True, blank=True, default=dict) + def status(self): + return self.PROGRESS_STATUS_CHOICES[self.model_status] + @staticmethod @transaction.atomic def create(package, name, description, rule_packages, data_packages, eval_packages, threshold): @@ -1201,7 +1324,7 @@ class MLRelativeReasoning(EPModel): self.save() mod = SparseLabelECC( - **s.DEFAULT_MODELS_PARAMS + **s.DEFAULT_DT_MODEL_PARAMS ) mod.fit(X, y) @@ -1247,7 +1370,7 @@ class MLRelativeReasoning(EPModel): y_train, y_test = y[train_index], y[test_index] model = SparseLabelECC( - **s.DEFAULT_MODELS_PARAMS + **s.DEFAULT_DT_MODEL_PARAMS ) model.fit(X_train, y_train) @@ -1500,11 +1623,15 @@ class Setting(EnviPathModel): max_nodes = models.IntegerField(null=False, blank=False, verbose_name='Setting Max Number of Nodes', default=30) rule_packages = models.ManyToManyField("Package", verbose_name="Setting Rule Packages", - related_name="setting_rule_packages") + related_name="setting_rule_packages", blank=True) model = models.ForeignKey('EPModel', verbose_name='Setting EPModel', on_delete=models.SET_NULL, null=True, blank=True) model_threshold = models.FloatField(null=True, blank=True, verbose_name='Setting Model Threshold', default=0.25) + @property + def url(self): + return '{}/setting/{}'.format(s.SERVER_URL, self.uuid) + @cached_property def applicable_rules(self): """ diff --git a/epdb/tasks.py b/epdb/tasks.py index e724d259..2dcb5b65 100644 --- a/epdb/tasks.py +++ b/epdb/tasks.py @@ -1,4 +1,6 @@ import logging +from typing import Optional + from celery.signals import worker_process_init from celery import shared_task from epdb.models import Pathway, Node, Edge, EPModel, Setting @@ -40,11 +42,40 @@ def evaluate_model(model_pk: int): @shared_task(queue='predict') -def predict(pw_pk: int, pred_setting_pk: int): +def predict(pw_pk: int, pred_setting_pk: int, limit: Optional[int] = None, node_pk: Optional[int] = None) -> Pathway: pw = Pathway.objects.get(id=pw_pk) setting = Setting.objects.get(id=pred_setting_pk) - spw = SPathway(prediction_setting=setting, persist=pw) - level = 0 - while not spw.done: - spw.predict_step(from_depth=level) - level += 1 + + pw.kv.update(**{'status': 'running'}) + pw.save() + + try: + # regular prediction + if limit is not None: + spw = SPathway(prediction_setting=setting, persist=pw) + level = 0 + while not spw.done: + spw.predict_step(from_depth=level) + level += 1 + + # break in case we are in incremental model + if limit != -1: + if level >= limit: + break + + elif node_pk is not None: + n = Node.objects.get(id=node_pk, pathway=pw) + spw = SPathway.from_pathway(pw) + spw.predict_step(from_node=n) + else: + raise ValueError("Neither limit nor node_pk given!") + + + + except Exception as e: + pw.kv.update({'status': 'failed'}) + pw.save() + raise e + + pw.kv.update(**{'status': 'completed'}) + pw.save() \ No newline at end of file diff --git a/epdb/templatetags/__init__.py b/epdb/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epdb/templatetags/envipytags.py b/epdb/templatetags/envipytags.py new file mode 100644 index 00000000..ce2fa9d3 --- /dev/null +++ b/epdb/templatetags/envipytags.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.filter +def classname(obj): + return obj.__class__.__name__ \ No newline at end of file diff --git a/epdb/urls.py b/epdb/urls.py index 8430aef0..86839530 100644 --- a/epdb/urls.py +++ b/epdb/urls.py @@ -13,6 +13,8 @@ urlpatterns = [ # Home re_path(r'^$', v.index, name='index'), + # re_path(r'^login', v.login, name='login'), + # Top level urls re_path(r'^package$', v.packages, name='packages'), re_path(r'^compound$', v.compounds, name='compounds'), @@ -53,11 +55,11 @@ urlpatterns = [ re_path(rf'^package/(?P{UUID})/pathway$', v.package_pathways, name='package pathway list'), re_path(rf'^package/(?P{UUID})/pathway/(?P{UUID})$', v.package_pathway, name='package pathway detail'), # Pathway Nodes - # re_path(rf'^package/(?P{UUID})/pathway(?P{UUID})/node$', v.package_pathway_nodes, name='package pathway node list'), + re_path(rf'^package/(?P{UUID})/pathway/(?P{UUID})/node$', v.package_pathway_nodes, name='package pathway node list'), re_path(rf'^package/(?P{UUID})/pathway/(?P{UUID})/node/(?P{UUID})$', v.package_pathway_node, name='package pathway node detail'), # Pathway Edges - # re_path(rf'^package/(?P{UUID})/pathway(?P{UUID})/edge$', v.package_pathway_edges, name='package pathway edge list'), - # re_path(rf'^package/(?P{UUID})/pathway(?P{UUID})/edge/(?P{UUID})$', v.package_pathway_edge, name='package pathway edge detail'), + re_path(rf'^package/(?P{UUID})/pathway/(?P{UUID})/edge$', v.package_pathway_edges, name='package pathway edge list'), + re_path(rf'^package/(?P{UUID})/pathway/(?P{UUID})/edge/(?P{UUID})$', v.package_pathway_edge, name='package pathway edge detail'), # Scenario re_path(rf'^package/(?P{UUID})/scenario$', v.package_scenarios, name='package scenario list'), re_path(rf'^package/(?P{UUID})/scenario/(?P{UUID})$', v.package_scenario, name='package scenario detail'), diff --git a/epdb/views.py b/epdb/views.py index e3a3d327..9973611e 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -1,5 +1,6 @@ import json import logging +from functools import wraps from typing import List, Dict, Any from django.conf import settings as s @@ -7,29 +8,71 @@ from django.contrib.auth import get_user_model from django.http import JsonResponse, HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest from django.shortcuts import render, redirect from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.decorators import login_required from utilities.chem import FormatConverter, IndigoUtils -from .logic import GroupManager, PackageManager, UserManager, SettingManager +from .logic import GroupManager, PackageManager, UserManager, SettingManager, SearchManager from .models import Package, GroupPackagePermission, Group, CompoundStructure, Compound, Reaction, Rule, Pathway, Node, \ EPModel, EnviFormer, MLRelativeReasoning, RuleBaseRelativeReasoning, Scenario, SimpleAmbitRule, APIToken, \ - UserPackagePermission, Permission, License, User + UserPackagePermission, Permission, License, User, Edge logger = logging.getLogger(__name__) def log_post_params(request): - for k, v in request.POST.items(): - logger.debug(f"{k}\t{v}") + if s.DEBUG: + for k, v in request.POST.items(): + logger.debug(f"{k}\t{v}") + + +def catch_exceptions(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + try: + return view_func(request, *args, **kwargs) + except Exception as e: + # Optionally return JSON or plain HttpResponse + if request.headers.get('Accept') == 'application/json': + return JsonResponse( + {'error': 'Internal server error. Please try again later.'}, + status=500 + ) + else: + return render(request, 'errors/error.html', get_base_context(request)) + return _wrapped_view + + +def editable(request, user): + url = request.build_absolute_uri(request.path) + if PackageManager.is_package_url(url): + _package = PackageManager.get_package_lp(request.build_absolute_uri()) + return PackageManager.writable(user, _package) + elif GroupManager.is_group_url(url): + _group = GroupManager.get_group_lp(request.build_absolute_uri()) + return GroupManager.writable(user, _group) + elif UserManager.is_user_url(url): + _user = UserManager.get_user_lp(request.build_absolute_uri()) + return UserManager.writable(user, _user) + elif url in [s.SERVER_URL, f"{s.SERVER_URL}/", f"{s.SERVER_URL}/package", f"{s.SERVER_URL}/user", + f"{s.SERVER_URL}/group", f"{s.SERVER_URL}/search"]: + return True + else: + print(f"Unknown url: {url}") + return False + def get_base_context(request) -> Dict[str, Any]: current_user = _anonymous_or_real(request) + can_edit = editable(request, current_user) + ctx = { 'title': 'enviPath', 'meta': { 'version': '0.0.1', 'server_url': s.SERVER_URL, 'user': current_user, + 'can_edit': can_edit, 'readable_packages': PackageManager.get_all_readable_packages(current_user, include_reviewed=True), 'writeable_packages': PackageManager.get_all_writeable_packages(current_user), 'available_groups': GroupManager.get_groups(current_user), @@ -65,8 +108,9 @@ def breadcrumbs(first_level_object=None, second_level_namespace=None, second_lev return bread +# @catch_exceptions + def index(request): - current_user = _anonymous_or_real(request) context = get_base_context(request) context['title'] = 'enviPath - Home' context['meta']['current_package'] = context['meta']['user'].default_package @@ -77,6 +121,16 @@ def index(request): return render(request, 'index/index.html', context) +# def login(request): +# current_user = _anonymous_or_real(request) +# if request.method == 'GET': +# context = get_base_context(request) +# context['title'] = 'enviPath' +# return render(request, 'login.html', context) +# else: +# return HttpResponseBadRequest() +# +# @login_required(login_url='/login') def packages(request): current_user = _anonymous_or_real(request) @@ -86,9 +140,10 @@ def packages(request): context['object_type'] = 'package' context['meta']['current_package'] = context['meta']['user'].default_package + context['meta']['can_edit'] = True - reviewed_package_qs = Package.objects.filter(reviewed=True) - unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user) + reviewed_package_qs = Package.objects.filter(reviewed=True).order_by('created') + unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user).order_by('name') context['reviewed_objects'] = reviewed_package_qs context['unreviewed_objects'] = unreviewed_package_qs @@ -103,7 +158,6 @@ def packages(request): else: package_name = request.POST.get('package-name') package_description = request.POST.get('package-description', s.DEFAULT_VALUES['description']) - # group = GroupManager.get_group_by_url(request.user, request.POST.get('package-group')) created_package = PackageManager.create_package(current_user, package_name, package_description) @@ -342,7 +396,23 @@ def models(request): def search(request): + current_user = _anonymous_or_real(request) + if request.method == 'GET': + package_urls = request.GET.getlist('packages') + searchterm = request.GET.get('search') + mode = request.GET.get('mode') + + # add HTTP_ACCEPT check to differentiate between index and ajax call + if 'application/json' in request.META.get('HTTP_ACCEPT') and all([searchterm, mode]): + if package_urls: + packages = [PackageManager.get_package_by_url(current_user, p) for p in package_urls] + else: + packages = PackageManager.get_reviewed_packages() + + search_result = SearchManager.search(packages, searchterm, mode) + return JsonResponse(search_result, safe=False) + context = get_base_context(request) context['title'] = 'enviPath - Search' @@ -352,13 +422,21 @@ def search(request): {'Home': s.SERVER_URL}, {'Search': s.SERVER_URL + '/search'}, ] - # TODO perm - reviewed_package_qs = Package.objects.filter(reviewed=True) - unreviewed_package_qs = Package.objects.filter(reviewed=False) + + reviewed_package_qs = PackageManager.get_reviewed_packages() + unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user) context['reviewed_objects'] = reviewed_package_qs context['unreviewed_objects'] = unreviewed_package_qs + if all([searchterm, mode]): + if package_urls: + packages = [PackageManager.get_package_by_url(current_user, p) for p in package_urls] + else: + packages = PackageManager.get_reviewed_packages() + + context['search_result'] = SearchManager.search(packages, searchterm, mode) + return render(request, 'search.html', context) @@ -487,7 +565,7 @@ def package_model(request, package_uuid, model_uuid): return render(request, 'objects/model.html', context) - if request.method == 'POST': + elif request.method == 'POST': if hidden := request.POST.get('hidden', None): if hidden == 'delete-model': current_model.delete() @@ -496,21 +574,9 @@ def package_model(request, package_uuid, model_uuid): return HttpResponseBadRequest() else: return HttpResponseBadRequest() - # - # new_compound_name = request.POST.get('compound-name') - # new_compound_description = request.POST.get('compound-description') - # - # if new_compound_name: - # current_compound.name = new_compound_name - # - # if new_compound_description: - # current_compound.description = new_compound_description - # - # if any([new_compound_name, new_compound_description]): - # current_compound.save() - # return redirect(current_compound.url) - # else: - # return HttpResponseBadRequest() + + else: + return HttpResponseBadRequest() def package(request, package_uuid): @@ -803,8 +869,7 @@ def package_rules(request, package_uuid): elif request.method == 'POST': - for k, v in request.POST.items(): - print(k, v) + log_post_params(request) # Generic params rule_name = request.POST.get('rule-name') @@ -817,8 +882,8 @@ def package_rules(request, package_uuid): # Obtain parameters as required by rule type if rule_type == 'SimpleAmbitRule': params['smirks'] = request.POST.get('rule-smirks') - params['reactant_smarts'] = request.POST.get('rule-reactant-smarts') - params['product_smarts'] = request.POST.get('rule-product-smarts') + params['reactant_filter_smarts'] = request.POST.get('rule-reactant-smarts') + params['product_filter_smarts'] = request.POST.get('rule-product-smarts') elif rule_type == 'SimpleRDKitRule': params['reaction_smarts'] = request.POST.get('rule-reaction-smarts') elif rule_type == 'ParallelRule': @@ -828,7 +893,7 @@ def package_rules(request, package_uuid): else: return HttpResponseBadRequest() - r = Rule.create(current_package, rule_type, name=rule_name, description=rule_description, **params) + r = Rule.create(rule_type=rule_type, package=current_package, name=rule_name, description=rule_description, **params) return redirect(r.url) else: @@ -854,7 +919,7 @@ def package_rule(request, package_uuid, rule_uuid): else: # isinstance(current_rule, ParallelRule) or isinstance(current_rule, SequentialRule): return render(request, 'objects/composite_rule.html', context) - if request.method == 'POST': + elif request.method == 'POST': if hidden := request.POST.get('hidden', None): if hidden == 'delete-rule': current_rule.delete() @@ -862,8 +927,23 @@ def package_rule(request, package_uuid, rule_uuid): else: return HttpResponseBadRequest() - # TODO update! + rule_name = request.POST.get('rule-name', '').strip() + rule_description = request.POST.get('rule-description', '').strip() + if rule_name: + current_rule.name = rule_name + + if rule_description: + current_rule.description = rule_description + + if any([rule_name, rule_description]): + current_rule.save() + return redirect(current_rule.url) + else: + return HttpResponseBadRequest() + + else: + return HttpResponseBadRequest() # https://envipath.org/package//reaction def package_reactions(request, package_uuid): @@ -912,6 +992,8 @@ def package_reactions(request, package_uuid): return redirect(r.url) + else: + return HttpResponseBadRequest() # https://envipath.org/package//reaction/ def package_reaction(request, package_uuid, reaction_uuid): @@ -931,7 +1013,7 @@ def package_reaction(request, package_uuid, reaction_uuid): return render(request, 'objects/reaction.html', context) - if request.method == 'POST': + elif request.method == 'POST': if hidden := request.POST.get('hidden', None): if hidden == 'delete-reaction': current_reaction.delete() @@ -954,6 +1036,8 @@ def package_reaction(request, package_uuid, reaction_uuid): else: return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() # https://envipath.org/package//pathway def package_pathways(request, package_uuid): @@ -989,7 +1073,7 @@ def package_pathways(request, package_uuid): return render(request, 'collections/objects_list.html', context) - if request.method == 'POST': + elif request.method == 'POST': log_post_params(request) @@ -1002,15 +1086,34 @@ def package_pathways(request, package_uuid): return HttpResponseBadRequest() stand_smiles = FormatConverter.standardize(smiles) - pw = Pathway.create(current_package, name, description, stand_smiles) - if pw_mode != 'build': + if pw_mode not in ['predict', 'build', 'incremental']: + return HttpResponseBadRequest() + + pw = Pathway.create(current_package, name, description, stand_smiles) + # set mode + pw.kv.update({'mode': pw_mode}) + pw.save() + + if pw_mode == 'predict' or pw_mode == 'incremental': + # unlimited pred (will be handled by setting) + limit = -1 + + # For incremental predict first level and return + if pw_mode == 'incremental': + limit = 1 + pred_setting = current_user.prediction_settings() + pw.setting = pred_setting + pw.save() + from .tasks import predict - predict.delay(pw.pk, pred_setting.pk) + predict.delay(pw.pk, pred_setting.pk, limit=limit) return redirect(pw.url) + else: + return HttpResponseBadRequest() # https://envipath.org/package//pathway/ def package_pathway(request, package_uuid, pathway_uuid): @@ -1043,7 +1146,7 @@ def package_pathway(request, package_uuid, pathway_uuid): return render(request, 'objects/pathway.html', context) # return render(request, 'pathway_playground2.html', context) - if request.method == 'POST': + elif request.method == 'POST': if hidden := request.POST.get('hidden', None): if hidden == 'delete-pathway': current_pathway.delete() @@ -1051,30 +1154,92 @@ def package_pathway(request, package_uuid, pathway_uuid): else: return HttpResponseBadRequest() + pathway_name = request.POST.get('pathway-name') + pathway_description = request.POST.get('pathway-description') -# -# -# -# def package_relative_reasonings(request, package_id): -# if request.method == 'GET': -# pass -# -# -# def package_relative_reasoning(request, package_id, relative_reasoning_id): -# current_user = _anonymous_or_real(request) -# -# if request.method == 'GET': -# pass -# elif request.method == 'POST': -# pass -# -# # -# # -# # # https://envipath.org/package//pathway//node -# # def package_pathway_nodes(request, package_id, pathway_id): -# # pass -# # -# # + if any([pathway_name, pathway_description]): + if pathway_name is not None and pathway_name.strip() != '': + pathway_name = pathway_name.strip() + + current_pathway.name = pathway_name + + if pathway_description is not None and pathway_description.strip() != '': + pathway_description = pathway_description.strip() + + current_pathway.description = pathway_description + + current_pathway.save() + return redirect(current_pathway.url) + + node_url = request.POST.get('node') + + if node_url: + n = current_pathway.get_node(node_url) + + from .tasks import predict + # Dont delay? + predict(current_pathway.pk, current_pathway.setting.pk, node_pk=n.pk) + return JsonResponse({'success': current_pathway.url}) + + return HttpResponseBadRequest() + + else: + return HttpResponseBadRequest() + +# https://envipath.org/package//pathway//node +def package_pathway_nodes(request, package_uuid, pathway_uuid): + current_user = _anonymous_or_real(request) + current_package = PackageManager.get_package_by_id(current_user, package_uuid) + current_pathway = Pathway.objects.get(package=current_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['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}, + {'Pathway': current_package.url + '/pathway'}, + {current_pathway.name: current_pathway.url}, + {'Node': current_pathway.url + '/node'}, + ] + + reviewed_node_qs = Node.objects.none() + unreviewed_node_qs = Node.objects.none() + + if current_package.reviewed: + reviewed_node_qs = Node.objects.filter(pathway=current_pathway).order_by('name') + else: + unreviewed_node_qs = Node.objects.filter(pathway=current_pathway).order_by('name') + + if request.GET.get('all'): + return JsonResponse({ + "objects": [ + {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + for pw in (reviewed_node_qs if current_package.reviewed else unreviewed_node_qs) + ] + }) + + context['reviewed_objects'] = reviewed_node_qs + context['unreviewed_objects'] = unreviewed_node_qs + + return render(request, 'collections/objects_list.html', context) + + elif request.method == 'POST': + + node_name = request.POST.get('node-name') + node_description = request.POST.get('node-description') + node_smiles = request.POST.get('node-smiles') + + current_pathway.add_node(node_smiles, name=node_name, description=node_description) + + return redirect(current_pathway.url) + + else: + return HttpResponseBadRequest() # https://envipath.org/package//pathway//node/ @@ -1091,19 +1256,129 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid): svg_data = current_node.as_svg 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['meta']['current_package'] = current_package + context['object_type'] = 'pathway' + + context['breadcrumbs'] = [ + {'Home': s.SERVER_URL}, + {'Package': s.SERVER_URL + '/package'}, + {current_package.name: current_package.url}, + {'Pathway': current_package.url + '/pathway'}, + {current_pathway.name: current_pathway.url}, + {'Node': current_pathway.url + '/node'}, + {current_node.name: current_node.url}, + ] + + context['node'] = current_node + + return render(request, 'objects/node.html', context) + + elif request.method == 'POST': + if s.DEBUG: + for k, v in request.POST.items(): + print(k, v) + + if hidden := request.POST.get('hidden', None): + if hidden == 'delete-node': + current_node.delete() + return redirect(current_pathway.url) + + else: + return HttpResponseBadRequest() + + +# https://envipath.org/package//pathway//edge +def package_pathway_edges(request, package_uuid, pathway_uuid): + current_user = _anonymous_or_real(request) + current_package = PackageManager.get_package_by_id(current_user, package_uuid) + current_pathway = Pathway.objects.get(package=current_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['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}, + {'Pathway': current_package.url + '/pathway'}, + {current_pathway.name: current_pathway.url}, + {'Edge': current_pathway.url + '/edge'}, + ] + + reviewed_edge_qs = Edge.objects.none() + unreviewed_edge_qs = Edge.objects.none() + + if current_package.reviewed: + reviewed_edge_qs = Edge.objects.filter(pathway=current_pathway).order_by('name') + else: + unreviewed_edge_qs = Edge.objects.filter(pathway=current_pathway).order_by('name') + + if request.GET.get('all'): + return JsonResponse({ + "objects": [ + {"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed} + for pw in (reviewed_edge_qs if current_package.reviewed else unreviewed_edge_qs) + ] + }) + + context['reviewed_objects'] = reviewed_edge_qs + context['unreviewed_objects'] = unreviewed_edge_qs + + return render(request, 'collections/objects_list.html', context) + + elif request.method == 'POST': + + edge_name = request.POST.get('edge-name') + edge_description = request.POST.get('edge-description') + edge_substrates = request.POST.getlist('edge-substrates') + edge_products = request.POST.getlist('edge-products') + + substrate_nodes = [current_pathway.get_node(url) for url in edge_substrates] + product_nodes = [current_pathway.get_node(url) for url in edge_products] + + # TODO in the future consider Rules here? + current_pathway.add_edge(substrate_nodes, product_nodes, name=edge_name, description=edge_description) + + return redirect(current_pathway.url) + + else: + return HttpResponseBadRequest() + + +# https://envipath.org/package//pathway//edge/ +def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid): + current_user = _anonymous_or_real(request) + current_package = PackageManager.get_package_by_id(current_user, package_uuid) + current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid) + current_edge = Edge.objects.get(pathway=current_pathway, uuid=edge_uuid) + + if request.method == 'GET': + is_image_request = request.GET.get('image') + if is_image_request: + if is_image_request == 'svg': + svg_data = current_edge.as_svg + return HttpResponse(svg_data, content_type="image/svg+xml") + + elif request.method == 'POST': + if s.DEBUG: + for k, v in request.POST.items(): + print(k, v) + + if hidden := request.POST.get('hidden', None): + if hidden == 'delete-edge': + current_edge.delete() + return redirect(current_pathway.url) + + else: + return HttpResponseBadRequest() + -# # -# # -# # # https://envipath.org/package//pathway//edge -# # def package_pathway_edges(request, package_id, pathway_id): -# # pass -# # -# # -# # # https://envipath.org/package//pathway//edge/ -# # def package_pathway_edge(request, package_id, pathway_id, edge_id): -# # pass -# # -# # # https://envipath.org/package//scenario def package_scenarios(request, package_uuid): current_user = _anonymous_or_real(request) @@ -1158,11 +1433,6 @@ def package_scenario(request, package_uuid, scenario_uuid): return render(request, 'objects/scenario.html', context) - - -### END UNTESTED - - ############## # User/Group # ############## @@ -1201,7 +1471,7 @@ def users(request): return render(request, 'errors/user_account_inactive.html', status=403) email = temp_user.email - except get_user_model().DoesNotExists: + except get_user_model().DoesNotExist: return HttpResponseBadRequest() user = authenticate(username=email, password=password) @@ -1289,6 +1559,18 @@ def user(request, user_uuid): logout(request) return redirect(s.SERVER_URL) + default_package = request.POST.get('default-package') + default_group = request.POST.get('default-group') + default_prediction_setting = request.POST.get('default-prediction-setting') + + if any([default_package, default_group, default_prediction_setting]): + current_user.default_package = PackageManager.get_package_by_url(current_user, default_package) + current_user.default_group = GroupManager.get_group_by_url(current_user, default_group) + current_user.default_setting = SettingManager.get_setting_by_url(current_user, default_prediction_setting) + current_user.save() + + return redirect(current_user.url) + prediction_model_pk = request.POST.get('model') prediction_threshold = request.POST.get('threshold') prediction_max_nodes = request.POST.get('max_nodes') @@ -1509,3 +1791,9 @@ def layout(request): def depict(request): if smiles := request.GET.get('smiles'): return HttpResponse(IndigoUtils.mol_to_svg(smiles), content_type='image/svg+xml') + + elif smirks := request.GET.get('smirks'): + query_smirks = request.GET.get('is_query_smirks', False) == 'true' + return HttpResponse(IndigoUtils.smirks_to_svg(smirks, query_smirks), content_type='image/svg+xml') + else: + return HttpResponseBadRequest() diff --git a/static/images/enviPy-screenshot.png b/static/images/enviPy-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..09676aa5355e99059906b1fcc9fd0042b0edba82 GIT binary patch literal 92757 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

HJX;?EkX`mMevF(mu!v~K1HA(1lA@TgVyC-oI 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 z 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"; } -function makeSearchList(divToAppend, jsonob) { - - if(jsonob.status){ - $(divToAppend).append('

'); - return; - } - - var content = makeAccordionHead("searchAccordion", "Results",""); - - for ( var type in jsonob){ - var obj = jsonob[type]; - var objs = ""; - for ( var x in obj) { - objs += "" - + obj[x].name + ""; - } - content += makeAccordionPanel("searchAccordion", type, objs, true); - } - $(divToAppend).append(content); - -} - function fillPRCurve(modelUri, onclick){ if (modelUri == '') { return; diff --git a/static/js/pw.js b/static/js/pw.js index ff9868d1..266e9917 100644 --- a/static/js/pw.js +++ b/static/js/pw.js @@ -1,5 +1,18 @@ console.log("loaded") + +function predictFromNode(url) { + $.post("", {node: url}) + .done(function (data) { + console.log("Success:", data); + window.location.href = data.success; + }) + .fail(function (xhr, status, error) { + console.error("Error:", xhr.status, xhr.responseText); + // show user-friendly message or log error + }); +} + // data = {{ pathway.d3_json | safe }}; // elem = 'vizdiv' function draw(pathway, elem) { @@ -48,7 +61,7 @@ function draw(pathway, elem) { const avgY = d3.mean(childNodes, d => d.y); n.fx = avgX; // keep level as is - n.fy = n.y; + n.fy = n.y; } } }); @@ -77,8 +90,100 @@ function draw(pathway, elem) { d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted")); } + // Wait one second before showing popup + var popupWaitBeforeShow = 1000; + // Keep Popup at least for one second + var popushowAtLeast = 1000; - const tooltip = d3.select("#tooltip"); + function pop_show_e(element) { + var e = element; + setTimeout(function () { + if ($(e).is(':hover')) { // if element is still hovered + $(e).popover("show"); + + // workaround to set fixed positions + pop = $(e).attr("aria-describedby") + h = $('#' + pop).height(); + $('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`) + setTimeout(function () { + var close = setInterval(function () { + if (!$(".popover:hover").length // mouse outside popover + && !$(e).is(':hover')) { // mouse outside element + $(e).popover('hide'); + clearInterval(close); + } + }, 100); + }, popushowAtLeast); + } + }, popupWaitBeforeShow); + } + + function pop_add(objects, title, contentFunction) { + objects.attr("id", "pop") + .attr("data-container", "body") + .attr("data-toggle", "popover") + .attr("data-placement", "right") + .attr("title", title); + + objects.each(function (d, i) { + options = {trigger: "manual", html: true, animation: false}; + this_ = this; + var p = $(this).popover(options).on("mouseenter", function () { + pop_show_e(this); + }); + p.on("show.bs.popover", function (e) { + // this is to dynamically ajdust the content and bounds of the popup + p.attr('data-content', contentFunction(d)); + p.data("bs.popover").setContent(); + p.data("bs.popover").tip().css({"max-width": "1000px"}); + }); + }); + } + + + function node_popup(n) { + popupContent = "" + n.name + "
"; + popupContent += "Depth " + n.depth + "
" + popupContent += "
" + if (n.scenarios.length > 0) { + popupContent += 'Half-lives and related scenarios:
' + for (var s of n.scenarios) { + popupContent += "" + s.name + "
"; + } + } + + var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0; + if(pathway.isIncremental && isLeaf) { + popupContent += '
Predict from here
'; + } + + return popupContent; + } + + function edge_popup(e) { + popupContent = "" + e.name + "
"; + popupContent += "
" + if (e.reaction_probability) { + popupContent += 'Probability:
' + e.reaction_probability.toFixed(3) + '
'; + } + + + if (e.scenarios.length > 0) { + popupContent += 'Half-lives and related scenarios:
' + for (var s of e.scenarios) { + popupContent += "" + s.name + "
"; + } + } + + return popupContent; + } + + var clientX; + var clientY; + document.addEventListener('mousemove', function(event) { + clientX = event.clientX; + clientY =event.clientY; + }); const zoomable = d3.select("#zoomable"); @@ -102,10 +207,10 @@ function draw(pathway, elem) { orig_depth = n.depth // console.log(n.id, parents) - for(idx in parents) { + for (idx in parents) { p = nodes[parents[idx]] // console.log(p.depth) - if(p.depth >= n.depth) { + if (p.depth >= n.depth) { // keep the .5 steps for pseudo nodes n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1); // console.log("Adjusting", orig_depth, Math.floor(p.depth + 1)); @@ -128,13 +233,15 @@ function draw(pathway, elem) { .enter().append("line") // Check if target is pseudo and draw marker only if not pseudo .attr("class", d => d.target.pseudo ? "link_no_arrow" : "link") - .on("mouseover", (event, d) => { - tooltip.style("visibility", "visible") - .text(`Link: ${d.source.id} → ${d.target.id}`) - .style("top", `${event.pageY + 5}px`) - .style("left", `${event.pageX + 5}px`); - }) - .on("mouseout", () => tooltip.style("visibility", "hidden")); + // .on("mouseover", (event, d) => { + // tooltip.style("visibility", "visible") + // .text(`Link: ${d.source.id} → ${d.target.id}`) + // .style("top", `${event.pageY + 5}px`) + // .style("left", `${event.pageX + 5}px`); + // }) + // .on("mouseout", () => tooltip.style("visibility", "hidden")); + + pop_add(link, "Reaction", edge_popup); // Knoten zeichnen const node = zoomable.append("g") @@ -145,19 +252,19 @@ function draw(pathway, elem) { .on("start", dragstarted) .on("drag", dragged) .on("end", dragended)) - .on("click", function(event, d) { + .on("click", function (event, d) { d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted")); }) - .on("mouseover", (event, d) => { - if (d.pseudo) { - return - } - tooltip.style("visibility", "visible") - .text(`Node: ${d.id} Depth: ${d.depth}`) - .style("top", `${event.pageY + 5}px`) - .style("left", `${event.pageX + 5}px`); - }) - .on("mouseout", () => tooltip.style("visibility", "hidden")); + // .on("mouseover", (event, d) => { + // if (d.pseudo) { + // return + // } + // tooltip.style("visibility", "visible") + // .text(`Node: ${d.id} Depth: ${d.depth}`) + // .style("top", `${event.pageY + 5}px`) + // .style("left", `${event.pageX + 5}px`); + // }) + // .on("mouseout", () => tooltip.style("visibility", "hidden")); // Kreise für die Knoten hinzufügen node.append("circle") @@ -172,4 +279,6 @@ function draw(pathway, elem) { .attr("y", -nodeRadius) .attr("width", nodeRadius * 2) .attr("height", nodeRadius * 2); + + pop_add(node, "Compound", node_popup); } diff --git a/templates/actions/collections/compound.html b/templates/actions/collections/compound.html index cd08bdcb..90551b07 100644 --- a/templates/actions/collections/compound.html +++ b/templates/actions/collections/compound.html @@ -1,4 +1,6 @@ -
  • - - New Compound -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + New Compound +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/compound_structure.html b/templates/actions/collections/compound_structure.html index db18a112..3e615cc1 100644 --- a/templates/actions/collections/compound_structure.html +++ b/templates/actions/collections/compound_structure.html @@ -1,4 +1,6 @@ -
  • - - New Compound Structure -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + New Compound Structure +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/edge.html b/templates/actions/collections/edge.html new file mode 100644 index 00000000..eb003dc1 --- /dev/null +++ b/templates/actions/collections/edge.html @@ -0,0 +1,6 @@ +{% if meta.can_edit %} +
  • + + New Edge +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/group.html b/templates/actions/collections/group.html index 5c666979..dd9054cf 100644 --- a/templates/actions/collections/group.html +++ b/templates/actions/collections/group.html @@ -1,4 +1,4 @@
  • New Group -
  • \ No newline at end of file + diff --git a/templates/actions/collections/model.html b/templates/actions/collections/model.html index 89008b2b..a384080b 100644 --- a/templates/actions/collections/model.html +++ b/templates/actions/collections/model.html @@ -1,4 +1,6 @@ -
  • - - New Model -
  • +{% if meta.can_edit %} +
  • + + New Model +
  • +{% endif %} diff --git a/templates/actions/collections/node.html b/templates/actions/collections/node.html new file mode 100644 index 00000000..968423f3 --- /dev/null +++ b/templates/actions/collections/node.html @@ -0,0 +1,6 @@ +{% if meta.can_edit %} +
  • + + New Node +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/pathway.html b/templates/actions/collections/pathway.html index 8268b0fd..bcfc043c 100644 --- a/templates/actions/collections/pathway.html +++ b/templates/actions/collections/pathway.html @@ -1,4 +1,6 @@ -
  • - - New Pathway -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + New Pathway +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/reaction.html b/templates/actions/collections/reaction.html index 144cc5e7..00d96f4f 100644 --- a/templates/actions/collections/reaction.html +++ b/templates/actions/collections/reaction.html @@ -1,4 +1,6 @@ -
  • - - New Reaction -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + New Reaction +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/rule.html b/templates/actions/collections/rule.html index 0fd48bd3..dbef29da 100644 --- a/templates/actions/collections/rule.html +++ b/templates/actions/collections/rule.html @@ -1,4 +1,6 @@ -
  • - - New Rule -
  • +{% if meta.can_edit %} +
  • + + New Rule +
  • +{% endif %} diff --git a/templates/actions/collections/scenario.html b/templates/actions/collections/scenario.html index 0055c8eb..f9ccc08e 100644 --- a/templates/actions/collections/scenario.html +++ b/templates/actions/collections/scenario.html @@ -1,4 +1,6 @@ -
  • - - New Scenario -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + New Scenario +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/collections/setting.html b/templates/actions/collections/setting.html index b3438bac..db8b95dc 100644 --- a/templates/actions/collections/setting.html +++ b/templates/actions/collections/setting.html @@ -1,4 +1,6 @@ -
  • - - New Setting -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + New Setting +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/compound.html b/templates/actions/objects/compound.html index ab69f18a..41bbcba2 100644 --- a/templates/actions/objects/compound.html +++ b/templates/actions/objects/compound.html @@ -1,12 +1,14 @@ -
  • - - Edit Compound -
  • -
  • - - Add Structure -
  • -
  • - - Delete Compound -
  • +{% if meta.can_edit %} +
  • + + Edit Compound +
  • +
  • + + Add Structure +
  • +
  • + + Delete Compound +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/compound_structure.html b/templates/actions/objects/compound_structure.html index e786638b..be8bd9a8 100644 --- a/templates/actions/objects/compound_structure.html +++ b/templates/actions/objects/compound_structure.html @@ -1,8 +1,10 @@ -
  • - - Edit Compound Structure -
  • -
  • - - Delete Compound Structure -
  • +{% if meta.can_edit %} +
  • + + Edit Compound Structure +
  • +
  • + + Delete Compound Structure +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/group.html b/templates/actions/objects/group.html index 1cd6f1dc..fb941c90 100644 --- a/templates/actions/objects/group.html +++ b/templates/actions/objects/group.html @@ -1,8 +1,10 @@ -
  • - - Delete Group -
  • -
  • - - Add/Remove Member -
  • \ No newline at end of file +{% if meta.can_edit %} +
  • + + Delete Group +
  • +
  • + + Add/Remove Member +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/model.html b/templates/actions/objects/model.html new file mode 100644 index 00000000..1047618c --- /dev/null +++ b/templates/actions/objects/model.html @@ -0,0 +1,6 @@ +{% if meta.can_edit %} +
  • + + Delete Model +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/node.html b/templates/actions/objects/node.html new file mode 100644 index 00000000..77352594 --- /dev/null +++ b/templates/actions/objects/node.html @@ -0,0 +1,10 @@ +{% if meta.can_edit %} +
  • + + Edit Node +
  • +
  • + + Delete Node +
  • +{% endif %} diff --git a/templates/actions/objects/package.html b/templates/actions/objects/package.html index d2e1dfa2..7ebaabb1 100644 --- a/templates/actions/objects/package.html +++ b/templates/actions/objects/package.html @@ -1,16 +1,18 @@ -
  • - - Edit Package -
  • -
  • - - Edit Permissions -
  • -
  • - - License -
  • -
  • - - Delete Package -
  • +{% if meta.can_edit %} +
  • + + Edit Package +
  • +
  • + + Edit Permissions +
  • +
  • + + License +
  • +
  • + + Delete Package +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/pathway.html b/templates/actions/objects/pathway.html index 007e337e..399adaa5 100644 --- a/templates/actions/objects/pathway.html +++ b/templates/actions/objects/pathway.html @@ -1,8 +1,32 @@ -
  • - - Edit Pathway -
  • -
  • - - Delete Pathway -
  • +{% if meta.can_edit %} +
  • + + Add Compound +
  • +
  • + + Add Reaction +
  • + +
  • + + Edit Pathway +
  • +{#
  • #} +{# #} +{# Calculate Compound Properties#} +{#
  • #} + +
  • + + Delete Compound +
  • +
  • + + Delete Reaction +
  • +
  • + + Delete Pathway +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/reaction.html b/templates/actions/objects/reaction.html index 617d0238..413ae1ba 100644 --- a/templates/actions/objects/reaction.html +++ b/templates/actions/objects/reaction.html @@ -1,8 +1,10 @@ -
  • - - Edit Reaction -
  • -
  • - - Delete Reaction -
  • +{% if meta.can_edit %} +
  • + + Edit Reaction +
  • +
  • + + Delete Reaction +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/rule.html b/templates/actions/objects/rule.html index 61345b0b..be1ec35c 100644 --- a/templates/actions/objects/rule.html +++ b/templates/actions/objects/rule.html @@ -1,4 +1,6 @@ -
  • - - Edit Rule -
  • +{% if meta.can_edit %} +
  • + + Edit Rule +
  • +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/scenario.html b/templates/actions/objects/scenario.html index e69de29b..c9ddab01 100644 --- a/templates/actions/objects/scenario.html +++ b/templates/actions/objects/scenario.html @@ -0,0 +1,2 @@ +{% if meta.can_edit %} +{% endif %} \ No newline at end of file diff --git a/templates/actions/objects/user.html b/templates/actions/objects/user.html index 0c4368d4..99d7f45a 100644 --- a/templates/actions/objects/user.html +++ b/templates/actions/objects/user.html @@ -1,20 +1,22 @@ -
  • - - Update -
  • -
  • - - Update Password -
  • -
  • - - New Prediction Setting -
  • -
  • - - Manage API Token -
  • -
  • - - Delete Account -
  • +{% if meta.can_edit %} +
  • + + Update +
  • +
  • + + Update Password +
  • +
  • + + New Prediction Setting +
  • +{#
  • #} +{# #} +{# Manage API Token#} +{#
  • #} +
  • + + Delete Account +
  • +{% endif %} \ No newline at end of file diff --git a/templates/collections/objects_list.html b/templates/collections/objects_list.html index 4c4ac16e..16d7e13d 100644 --- a/templates/collections/objects_list.html +++ b/templates/collections/objects_list.html @@ -35,12 +35,16 @@ {% include "modals/collections/new_reaction_modal.html" %} {% elif object_type == 'pathway' %} {# {% include "modals/collections/new_pathway_modal.html" %} #} +{% elif object_type == 'node' %} +{% include "modals/collections/new_node_modal.html" %} +{% elif object_type == 'edge' %} +{% include "modals/collections/new_edge_modal.html" %} {% elif object_type == 'scenario' %} {% include "modals/collections/new_scenario_modal.html" %} {% elif object_type == 'model' %} {% include "modals/collections/new_model_modal.html" %} {% elif object_type == 'setting' %} -{% include "modals/collections/new_setting_modal.html" %} +{#{% include "modals/collections/new_setting_modal.html" %}#} {% elif object_type == 'user' %}
    {% elif object_type == 'group' %} @@ -63,6 +67,10 @@ Reactions {% elif object_type == 'pathway' %} Pathways + {% elif object_type == 'node' %} + Nodes + {% elif object_type == 'edge' %} + Edges {% elif object_type == 'scenario' %} Scenarios {% elif object_type == 'model' %} @@ -75,34 +83,38 @@ Groups {% endif %} @@ -133,6 +145,14 @@

    A pathway displays the (predicted) biodegradation of a compound as graph. Learn more >>

    + {% elif object_type == 'node' %} +

    Nodes represent the (predicted) compounds in a graph. + Learn more + >>

    + {% elif object_type == 'edge' %} +

    Edges represent the links between Nodes in a graph + Learn more + >>

    {% elif object_type == 'scenario' %}

    A scenario contains meta-information that can be attached to other data (compounds, rules, ..). Learn more @@ -185,7 +205,7 @@ {% endfor %} {% else %} {% for obj in reviewed_objects|slice:":50" %} - {{ obj.name }} + {{ obj.name }}{# ({{ obj.package.name }}) #} ' + obj.name + ''); + } else { + $('#UnreviewedContent').append('' + obj.name + ''); + } + } + + $('#load-all-loading').empty(); + $('#load-remaining').hide(); + }).fail(function (resp) { + $('#load-all-loading').empty(); + $('#load-all-error').show(); + }); + }); + } + + $('#object-search').on('keyup', function () { + let query = $(this).val().toLowerCase(); + $('a.list-group-item').each(function () { + let text = $(this).text().toLowerCase(); + $(this).toggle(text.indexOf(query) !== -1); + }); + }); + + }); + {% endblock content %} diff --git a/templates/errors/error.html b/templates/errors/error.html new file mode 100644 index 00000000..1db057f2 --- /dev/null +++ b/templates/errors/error.html @@ -0,0 +1,14 @@ +{% extends "framework.html" %} +{% load static %} +{% block content %} + +

    + +{% endblock content %} diff --git a/templates/errors/user_account_inactive.html b/templates/errors/user_account_inactive.html index 89d451ac..3b656b77 100644 --- a/templates/errors/user_account_inactive.html +++ b/templates/errors/user_account_inactive.html @@ -2,9 +2,9 @@ {% load static %} {% block content %} -