diff --git a/epapi/v1/endpoints/settings.py b/epapi/v1/endpoints/settings.py new file mode 100644 index 00000000..dc6ce330 --- /dev/null +++ b/epapi/v1/endpoints/settings.py @@ -0,0 +1,23 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate + +from epdb.logic import SettingManager + +from ..pagination import EnhancedPageNumberPagination +from ..schemas import SettingOutSchema + +router = Router() + + +@router.get("/settings/", response=EnhancedPageNumberPagination.Output[SettingOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, +) +def list_all_pathways(request): + """ + List all pathways from reviewed packages. + """ + user = request.user + return SettingManager.get_all_settings(user) diff --git a/epapi/v1/router.py b/epapi/v1/router.py index a5cd2dea..fa2e7966 100644 --- a/epapi/v1/router.py +++ b/epapi/v1/router.py @@ -1,7 +1,18 @@ from ninja import Router from ninja.security import SessionAuth + from .auth import BearerTokenAuth -from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure +from .endpoints import ( + compounds, + models, + packages, + pathways, + reactions, + rules, + scenarios, + settings, + structure, +) # Main router with authentication router = Router( @@ -20,3 +31,4 @@ router.add_router("", reactions.router) router.add_router("", pathways.router) router.add_router("", models.router) router.add_router("", structure.router) +router.add_router("", settings.router) diff --git a/epapi/v1/schemas.py b/epapi/v1/schemas.py index b5620d86..7d6ac60f 100644 --- a/epapi/v1/schemas.py +++ b/epapi/v1/schemas.py @@ -102,3 +102,10 @@ class PackageOutSchema(Schema): @staticmethod def resolve_review_status(obj): return "reviewed" if obj.reviewed else "unreviewed" + + +class SettingOutSchema(Schema): + uuid: UUID + url: str = "" + name: str + description: str diff --git a/epdb/admin.py b/epdb/admin.py index aaa26a6e..6f9f4e39 100644 --- a/epdb/admin.py +++ b/epdb/admin.py @@ -28,7 +28,7 @@ Package = s.GET_PACKAGE_MODEL() class UserAdmin(admin.ModelAdmin): - list_display = ["username", "email", "is_active"] + list_display = ["username", "email", "is_active", "is_staff", "is_superuser"] class UserPackagePermissionAdmin(admin.ModelAdmin): @@ -48,7 +48,7 @@ class JobLogAdmin(admin.ModelAdmin): class EPAdmin(admin.ModelAdmin): - search_fields = ["name", "description"] + search_fields = ["name", "description", "url", "uuid"] list_display = ["name", "url", "created"] ordering = ["-created"] diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py index 747d4ce7..d4cc4729 100644 --- a/epdb/legacy_api.py +++ b/epdb/legacy_api.py @@ -18,6 +18,8 @@ from .models import ( Edge, EnviFormer, EPModel, + Group, + GroupPackagePermission, MLRelativeReasoning, Node, PackageBasedModel, @@ -204,6 +206,82 @@ def get_user(request, user_uuid): } +######## +# Group # +######## + + +class GroupMember(Schema): + id: str = Field(None, alias="url") + identifier: str + name: str + + +class GroupWrapper(Schema): + group: List[SimpleGroup] + + +class GroupSchema(Schema): + description: str + id: str = Field(None, alias="url") + identifier: str = "group" + members: List[GroupMember] = Field([], alias="members") + name: str = Field(None, alias="name") + ownerid: str = Field(None, alias="owner.url") + ownername: str = Field(None, alias="owner.name") + packages: List["SimplePackage"] = Field([], alias="packages") + readers: List[GroupMember] = Field([], alias="readers") + writers: List[GroupMember] = Field([], alias="writers") + + @staticmethod + def resolve_members(obj: Group): + res = [] + for member in obj.user_member.all(): + res.append(GroupMember(id=member.url, identifier="usermember", name=member.username)) + + for member in obj.group_member.all(): + res.append(GroupMember(id=member.url, identifier="groupmember", name=member.name)) + + return res + + @staticmethod + def resolve_packages(obj: Group): + return Package.objects.filter( + id__in=[ + GroupPackagePermission.objects.filter(group=obj).values_list( + "package_id", flat=True + ) + ] + ) + + @staticmethod + def resolve_readers(obj: Group): + return GroupSchema.resolve_members(obj) + + @staticmethod + def resolve_writers(obj: Group): + return [GroupMember(id=obj.owner.url, identifier="usermember", name=obj.owner.username)] + + +@router.get("/group", response={200: GroupWrapper, 403: Error}) +def get_groups(request): + return {"group": GroupManager.get_groups(request.user)} + + +@router.get("/group/{uuid:group_uuid}", response={200: GroupSchema, 403: Error}) +def get_group(request, group_uuid): + try: + g = GroupManager.get_group_by_id(request.user, group_uuid) + return g + except ValueError: + return 403, { + "message": f"Getting Group with id {group_uuid} failed due to insufficient rights!" + } + + +########## +# Search # +########## class Search(Schema): packages: List[str] = Field(alias="packages[]") search: str diff --git a/epdb/migrations/0015_user_is_reviewer.py b/epdb/migrations/0015_user_is_reviewer.py new file mode 100644 index 00000000..b26db739 --- /dev/null +++ b/epdb/migrations/0015_user_is_reviewer.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-01-19 19:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("epdb", "0014_rename_expansion_schema_setting_expansion_scheme"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_reviewer", + field=models.BooleanField(default=False), + ), + ] diff --git a/epdb/models.py b/epdb/models.py index 864aa429..900e1d4a 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -72,6 +72,7 @@ class User(AbstractUser): null=True, blank=False, ) + is_reviewer = models.BooleanField(default=False) USERNAME_FIELD = "email" REQUIRED_FIELDS = ["username"] @@ -1828,8 +1829,8 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): queue.append(prod) # We shouldn't lose or make up nodes... - assert len(nodes) == len(self.nodes) - logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}") + if len(nodes) != len(self.nodes): + logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}") links = [e.d3_json() for e in self.edges] @@ -1880,6 +1881,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): "source": pseudo_idx, "target": node_url_to_idx[target], "app_domain": link.get("app_domain", None), + "multi_step": link["multi_step"], } adjusted_links.append(new_link) @@ -2146,6 +2148,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): } @staticmethod + @transaction.atomic def create( pathway: "Pathway", smiles: str, @@ -2185,8 +2188,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): if data: rule_ids = defaultdict(list) for e in Edge.objects.filter(start_nodes__in=[self]): - for r in e.edge_label.rules.all(): - rule_ids[str(r.uuid)].append(e.simple_json()) + # TODO While the Pathway is being predicted we sometimes + # TODO receive 'NoneType' object has no attribute 'rules' + if e.edge_label: + for r in e.edge_label.rules.all(): + rule_ids[str(r.uuid)].append(e.simple_json()) for t in data["assessment"]["transformations"]: if t["rule"]["uuid"] in rule_ids: @@ -2230,6 +2236,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): "reaction": {"name": self.edge_label.name, "url": self.edge_label.url} 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()], @@ -2270,6 +2277,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): return edge_json @staticmethod + @transaction.atomic def create( pathway, start_nodes: List[Node], @@ -3819,6 +3827,11 @@ class Scenario(EnviPathModel): yield inst + def related_pathways(self): + return Pathway.objects.filter( + scenarios__in=[self], package__reviewed=True, package=self.package + ).distinct() + class UserSettingPermission(Permission): uuid = models.UUIDField( diff --git a/epdb/views.py b/epdb/views.py index dde70291..2d1595f2 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -1352,6 +1352,14 @@ def package_compound_structure(request, package_uuid, compound_uuid, structure_u ) if request.method == "GET": + is_image_request = request.GET.get("image") + + if is_image_request: + if is_image_request == "svg": + return HttpResponse(current_structure.as_svg, content_type="image/svg+xml") + else: + return HttpResponseBadRequest("Currently only SVG as image formate supported!") + context = get_base_context(request) context["title"] = ( f"enviPath - {current_package.name} - {current_compound.name} - {current_structure.name}" @@ -2569,6 +2577,28 @@ def package_scenario(request, package_uuid, scenario_uuid): return redirect(current_scenario.url) else: return HttpResponseBadRequest() + + new_scenario_name = request.POST.get("scenario-name") + + if new_scenario_name is not None: + new_scenario_name = nh3.clean(new_scenario_name, tags=s.ALLOWED_HTML_TAGS).strip() + + if new_scenario_name: + current_scenario.name = new_scenario_name + + new_scenario_description = request.POST.get("scenario-description") + + if new_scenario_description is not None: + new_scenario_description = nh3.clean( + new_scenario_description, tags=s.ALLOWED_HTML_TAGS + ).strip() + + if new_scenario_description: + current_scenario.description = new_scenario_description + + if any([new_scenario_name, new_scenario_description]): + current_scenario.save() + return redirect(current_scenario.url) else: return HttpResponseBadRequest() else: @@ -2784,10 +2814,17 @@ def settings(request): context["object_type"] = "setting" context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, - {"Group": s.SERVER_URL + "/setting"}, + {"Setting": s.SERVER_URL + "/setting"}, ] - context["objects"] = SettingManager.get_all_settings(current_user) + # Context for paginated template + context["entity_type"] = "setting" + context["api_endpoint"] = "/api/v1/settings/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "settings" + context["list_mode"] = "combined" + + return render(request, "collections/settings_paginated.html", context) return render(request, "collections/objects_list.html", context) elif request.method == "POST": @@ -2867,7 +2904,26 @@ def settings(request): def setting(request, setting_uuid): - pass + current_user = _anonymous_or_real(request) + current_setting = SettingManager.get_setting_by_id(current_user, setting_uuid) + + if request.method == "GET": + context = get_base_context(request) + context["title"] = f"enviPath - {current_setting.name}" + + context["object_type"] = "setting" + context["breadcrumbs"] = [ + {"Home": s.SERVER_URL}, + {"Setting": s.SERVER_URL + "/setting"}, + {f"{current_setting.name}": current_setting.url}, + ] + + context["setting"] = current_setting + context["current_object"] = current_setting + + return render(request, "objects/setting.html", context) + else: + return HttpResponseNotAllowed(["GET"]) def jobs(request): diff --git a/static/js/pw.js b/static/js/pw.js index f514f975..7320c05e 100644 --- a/static/js/pw.js +++ b/static/js/pw.js @@ -498,7 +498,8 @@ 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") - .attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)') + .attr("marker-end", d => d.target.pseudo ? '' : d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)') + // add element to links array link.each(function (d) { diff --git a/templates/actions/objects/scenario.html b/templates/actions/objects/scenario.html index 532ef6b1..ce38ba5d 100644 --- a/templates/actions/objects/scenario.html +++ b/templates/actions/objects/scenario.html @@ -1,4 +1,12 @@ {% if meta.can_edit %} +
+ Rebuilt from the ground up for faster predictions, greater stability,
+ and powerful scalability. Explore a decade of research on a modern,
+ reliable platform.
+ The old system is still accessible but will be shut down at a later
+ date. If you want to back up your data, download it from the
+ old system
+ or contact us via the
+ community forum
+ for assistance.
+