From 422ed77d898b041721629f37b8ded4e6e876fd32 Mon Sep 17 00:00:00 2001 From: kushvinth Date: Fri, 6 Mar 2026 14:12:25 +0530 Subject: [PATCH] Add serviceapps Django app for Cytoscape Web Service Apps Implements support for Cytoscape Web Service Apps as described in the Service App Specification (draft v2). Service Apps are web services discoverable and installable via the App Store UI and a machine-readable API endpoint for Cytoscape Web. New serviceapps app includes: - ServiceApp model with URL, metadata, health status tracking - Submit view that validates endpoints (/ and /status) per the spec - List and detail views with filtering by health status - JSON API endpoint at /api/service-apps/config for Cytoscape Web - Admin integration for managing service apps - Management command (check_service_apps) for periodic health checks - Unit tests for submission, listing, detail, and config API Wired into settings/base.py INSTALLED_APPS and root urls.py. Added requests to requirements.txt. Closes #115 --- requirements.txt | 1 + serviceapps/__init__.py | 14 ++ serviceapps/admin.py | 10 + serviceapps/apps.py | 7 + serviceapps/forms.py | 8 + serviceapps/management/__init__.py | 0 serviceapps/management/commands/__init__.py | 0 .../management/commands/check_service_apps.py | 32 ++++ serviceapps/migrations/0001_initial.py | 36 ++++ serviceapps/migrations/__init__.py | 0 serviceapps/models.py | 32 ++++ serviceapps/templates/serviceapps/detail.html | 29 +++ serviceapps/templates/serviceapps/list.html | 27 +++ serviceapps/templates/serviceapps/submit.html | 11 ++ serviceapps/tests.py | 173 ++++++++++++++++++ serviceapps/urls.py | 17 ++ serviceapps/views.py | 106 +++++++++++ settings/base.py | 1 + urls.py | 1 + 19 files changed, 505 insertions(+) create mode 100644 serviceapps/__init__.py create mode 100644 serviceapps/admin.py create mode 100644 serviceapps/apps.py create mode 100644 serviceapps/forms.py create mode 100644 serviceapps/management/__init__.py create mode 100644 serviceapps/management/commands/__init__.py create mode 100644 serviceapps/management/commands/check_service_apps.py create mode 100644 serviceapps/migrations/0001_initial.py create mode 100644 serviceapps/migrations/__init__.py create mode 100644 serviceapps/models.py create mode 100644 serviceapps/templates/serviceapps/detail.html create mode 100644 serviceapps/templates/serviceapps/list.html create mode 100644 serviceapps/templates/serviceapps/submit.html create mode 100644 serviceapps/tests.py create mode 100644 serviceapps/urls.py create mode 100644 serviceapps/views.py diff --git a/requirements.txt b/requirements.txt index e4aff77af..1d2724320 100755 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ urllib3 Whoosh==2.7.4 django-haystack==3.3.0 Pillow +requests gevent diff --git a/serviceapps/__init__.py b/serviceapps/__init__.py new file mode 100644 index 000000000..233409f14 --- /dev/null +++ b/serviceapps/__init__.py @@ -0,0 +1,14 @@ +import sys + +# manage.py adds '..' to sys.path and the project root has __init__.py, +# making this package importable as both 'serviceapps' and +# 'appstore.serviceapps'. Canonicalize to the short name so Django +# does not register models twice. +_CANONICAL = 'serviceapps' +if __name__ != _CANONICAL and _CANONICAL in sys.modules: + sys.modules[__name__] = sys.modules[_CANONICAL] + for _key in list(sys.modules): + if _key.startswith(_CANONICAL + '.'): + _alt = __name__ + _key[len(_CANONICAL):] + if _alt not in sys.modules: + sys.modules[_alt] = sys.modules[_key] diff --git a/serviceapps/admin.py b/serviceapps/admin.py new file mode 100644 index 000000000..c432be283 --- /dev/null +++ b/serviceapps/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import ServiceApp + + +@admin.register(ServiceApp) +class ServiceAppAdmin(admin.ModelAdmin): + list_display = ("display_name", "service_url", "last_status", + "last_checked", "is_active") + list_filter = ("last_status", "is_active") + search_fields = ("display_name", "service_url", "author", "description") diff --git a/serviceapps/apps.py b/serviceapps/apps.py new file mode 100644 index 000000000..a1ef68eb8 --- /dev/null +++ b/serviceapps/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ServiceappsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "serviceapps" + verbose_name = "Service Apps" diff --git a/serviceapps/forms.py b/serviceapps/forms.py new file mode 100644 index 000000000..a4d6988d2 --- /dev/null +++ b/serviceapps/forms.py @@ -0,0 +1,8 @@ +from django import forms + + +class ServiceAppSubmitForm(forms.Form): + service_url = forms.URLField( + label="Service base URL", + help_text="Example: https://example.org/myservice" + ) diff --git a/serviceapps/management/__init__.py b/serviceapps/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/serviceapps/management/commands/__init__.py b/serviceapps/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/serviceapps/management/commands/check_service_apps.py b/serviceapps/management/commands/check_service_apps.py new file mode 100644 index 000000000..0b566323d --- /dev/null +++ b/serviceapps/management/commands/check_service_apps.py @@ -0,0 +1,32 @@ +import json + +import requests +from django.core.management.base import BaseCommand +from django.utils import timezone + +from serviceapps.models import ServiceApp + + +class Command(BaseCommand): + help = "Ping /status for all active Service Apps" + + def handle(self, *args, **opts): + for sa in ServiceApp.objects.filter(is_active=True): + try: + r = requests.get( + sa.service_url.rstrip("/") + "/status", + timeout=8, + headers={"Accept": "application/json"}, + ) + r.raise_for_status() + data = r.json() + sa.last_status = ("ok" if data.get("status") == "ok" + else "unavailable") + sa.last_message = ("" if sa.last_status == "ok" + else json.dumps(data)[:500]) + except Exception as e: + sa.last_status = "error" + sa.last_message = str(e)[:500] + sa.last_checked = timezone.now() + sa.save() + self.stdout.write(f"{sa.service_url} -> {sa.last_status}") diff --git a/serviceapps/migrations/0001_initial.py b/serviceapps/migrations/0001_initial.py new file mode 100644 index 000000000..f6f75be1e --- /dev/null +++ b/serviceapps/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.15 on 2026-03-06 14:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ServiceApp', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('service_url', models.URLField(help_text='Base URL of the service', unique=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('display_name', models.CharField(blank=True, max_length=255)), + ('description', models.TextField(blank=True)), + ('author', models.CharField(blank=True, max_length=255)), + ('version', models.CharField(blank=True, max_length=64)), + ('is_active', models.BooleanField(default=True)), + ('last_checked', models.DateTimeField(blank=True, null=True)), + ('last_status', models.CharField(default='unknown', max_length=32)), + ('last_message', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['display_name', 'service_url'], + 'indexes': [models.Index(fields=['is_active', 'last_status'], name='serviceapps_is_acti_010135_idx'), models.Index(fields=['display_name'], name='serviceapps_display_8ce2b9_idx')], + }, + ), + ] diff --git a/serviceapps/migrations/__init__.py b/serviceapps/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/serviceapps/models.py b/serviceapps/models.py new file mode 100644 index 000000000..4dff60fff --- /dev/null +++ b/serviceapps/models.py @@ -0,0 +1,32 @@ +from django.db import models + + +class ServiceApp(models.Model): + service_url = models.URLField(unique=True, help_text="Base URL of the service") + metadata = models.JSONField(default=dict, blank=True) + + display_name = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True) + author = models.CharField(max_length=255, blank=True) + version = models.CharField(max_length=64, blank=True) + + is_active = models.BooleanField(default=True) + last_checked = models.DateTimeField(null=True, blank=True) + last_status = models.CharField(max_length=32, default="unknown") + last_message = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["display_name", "service_url"] + indexes = [ + models.Index(fields=["is_active", "last_status"]), + models.Index(fields=["display_name"]), + ] + + def __str__(self): + return self.display_name or self.service_url + + def as_app_definition(self): + return {"url": self.service_url} diff --git a/serviceapps/templates/serviceapps/detail.html b/serviceapps/templates/serviceapps/detail.html new file mode 100644 index 000000000..5c6f8e6f6 --- /dev/null +++ b/serviceapps/templates/serviceapps/detail.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %} - {{ object.display_name }}{% endblock %} +{% block content %} +

{{ object.display_name }}

+

{{ object.description }}

+ +
+
Service URL
+
{{ object.service_url }}
+
Author
+
{{ object.author }}
+
Version
+
{{ object.version }}
+
Status
+
{{ object.last_status }}
+
+ +{% if object.metadata.parameters %} +

Parameters

+
{{ object.metadata.parameters }}
+{% endif %} + +{% if object.metadata.cyWebActions %} +

Actions

+
{{ object.metadata.cyWebActions }}
+{% endif %} + +

View JSON Config

+{% endblock %} diff --git a/serviceapps/templates/serviceapps/list.html b/serviceapps/templates/serviceapps/list.html new file mode 100644 index 000000000..07f19c40d --- /dev/null +++ b/serviceapps/templates/serviceapps/list.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %} - Service Apps{% endblock %} +{% block content %} +

Service Apps

+ +
+ + + +
+ + +{% endblock %} diff --git a/serviceapps/templates/serviceapps/submit.html b/serviceapps/templates/serviceapps/submit.html new file mode 100644 index 000000000..50ab48ee9 --- /dev/null +++ b/serviceapps/templates/serviceapps/submit.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %} - Submit a Service App{% endblock %} +{% block content %} +

Submit a Service App

+

Enter the base URL of your Cytoscape Web Service App.

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/serviceapps/tests.py b/serviceapps/tests.py new file mode 100644 index 000000000..368a892d0 --- /dev/null +++ b/serviceapps/tests.py @@ -0,0 +1,173 @@ +from django.test import TestCase +from django.urls import reverse +from unittest.mock import patch, Mock + +from .models import ServiceApp + +ROOT_META = { + "name": "Demo Service", + "description": "Example", + "version": "1.0.0", + "parameters": [], + "serviceInputDefinition": {}, + "cyWebActions": [], + "cyWebMenuItem": {"title": "Tools/Demo"}, +} + +STATUS_OK = {"status": "ok"} + + +class ServiceAppModelTests(TestCase): + def test_str_with_display_name(self): + sa = ServiceApp(display_name="My App", service_url="https://example.org") + self.assertEqual(str(sa), "My App") + + def test_str_without_display_name(self): + sa = ServiceApp(service_url="https://example.org") + self.assertEqual(str(sa), "https://example.org") + + def test_as_app_definition(self): + sa = ServiceApp(service_url="https://example.org/demo") + self.assertEqual(sa.as_app_definition(), {"url": "https://example.org/demo"}) + + +class SubmitServiceAppTests(TestCase): + @patch("serviceapps.views.requests.get") + def test_submit_valid_service(self, mget): + def _resp(url, *args, **kwargs): + mock = Mock() + mock.raise_for_status = lambda: None + if url.endswith("/status"): + mock.json = lambda: STATUS_OK + else: + mock.json = lambda: ROOT_META + return mock + mget.side_effect = _resp + + resp = self.client.post( + reverse("serviceapps:submit"), + {"service_url": "https://example.org/demo"}, + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(ServiceApp.objects.count(), 1) + + sa = ServiceApp.objects.first() + self.assertEqual(sa.display_name, "Demo Service") + self.assertEqual(sa.last_status, "ok") + + @patch("serviceapps.views.requests.get") + def test_submit_missing_keys(self, mget): + def _resp(url, *args, **kwargs): + mock = Mock() + mock.raise_for_status = lambda: None + mock.json = lambda: {"name": "Incomplete"} + return mock + mget.side_effect = _resp + + resp = self.client.post( + reverse("serviceapps:submit"), + {"service_url": "https://example.org/bad"}, + ) + self.assertEqual(resp.status_code, 200) # re-renders form with errors + self.assertEqual(ServiceApp.objects.count(), 0) + + @patch("serviceapps.views.requests.get") + def test_submit_unreachable(self, mget): + mget.side_effect = Exception("Connection refused") + + resp = self.client.post( + reverse("serviceapps:submit"), + {"service_url": "https://example.org/down"}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(ServiceApp.objects.count(), 0) + + +class ServiceAppConfigTests(TestCase): + def test_config_returns_active_ok_apps(self): + ServiceApp.objects.create( + service_url="https://a.example.org", + display_name="A", + is_active=True, + last_status="ok", + ) + ServiceApp.objects.create( + service_url="https://b.example.org", + display_name="B", + is_active=True, + last_status="error", + ) + ServiceApp.objects.create( + service_url="https://c.example.org", + display_name="C", + is_active=False, + last_status="ok", + ) + + resp = self.client.get(reverse("serviceapps:config")) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["url"], "https://a.example.org") + + +class ServiceAppListTests(TestCase): + def test_list_shows_only_active(self): + ServiceApp.objects.create( + service_url="https://active.example.org", + display_name="Active", + is_active=True, + last_status="ok", + ) + ServiceApp.objects.create( + service_url="https://inactive.example.org", + display_name="Inactive", + is_active=False, + last_status="ok", + ) + + resp = self.client.get(reverse("serviceapps:list")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Active") + self.assertNotContains(resp, "Inactive") + + def test_list_filter_by_health(self): + ServiceApp.objects.create( + service_url="https://ok.example.org", + display_name="OK App", + is_active=True, + last_status="ok", + ) + ServiceApp.objects.create( + service_url="https://err.example.org", + display_name="Err App", + is_active=True, + last_status="error", + ) + + resp = self.client.get(reverse("serviceapps:list"), {"health": "ok"}) + self.assertContains(resp, "OK App") + self.assertNotContains(resp, "Err App") + + +class ServiceAppDetailTests(TestCase): + def test_detail_active_app(self): + sa = ServiceApp.objects.create( + service_url="https://detail.example.org", + display_name="Detail App", + is_active=True, + last_status="ok", + ) + resp = self.client.get(reverse("serviceapps:detail", args=[sa.pk])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Detail App") + + def test_detail_inactive_app_404(self): + sa = ServiceApp.objects.create( + service_url="https://gone.example.org", + display_name="Gone", + is_active=False, + last_status="ok", + ) + resp = self.client.get(reverse("serviceapps:detail", args=[sa.pk])) + self.assertEqual(resp.status_code, 404) diff --git a/serviceapps/urls.py b/serviceapps/urls.py new file mode 100644 index 000000000..bc9e1cb6b --- /dev/null +++ b/serviceapps/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + SubmitServiceAppView, + ServiceAppListView, + ServiceAppDetailView, + service_apps_config, +) + +app_name = "serviceapps" + +urlpatterns = [ + path("submit/service-app/", SubmitServiceAppView.as_view(), name="submit"), + path("service-apps/", ServiceAppListView.as_view(), name="list"), + path("service-apps//", ServiceAppDetailView.as_view(), name="detail"), + path("api/service-apps/config", service_apps_config, name="config"), +] diff --git a/serviceapps/views.py b/serviceapps/views.py new file mode 100644 index 000000000..7c9a414af --- /dev/null +++ b/serviceapps/views.py @@ -0,0 +1,106 @@ +import logging + +import requests +from django.http import JsonResponse +from django.urls import reverse +from django.utils import timezone +from django.views.generic import FormView, ListView, DetailView + +from .forms import ServiceAppSubmitForm +from .models import ServiceApp + +logger = logging.getLogger(__name__) + +SPEC_TIMEOUT = 8 + +REQUIRED_TOP_KEYS = [ + "name", + "description", + "version", + "parameters", + "serviceInputDefinition", + "cyWebActions", + "cyWebMenuItem", +] + + +def _fetch_json(url): + r = requests.get(url, timeout=SPEC_TIMEOUT, + headers={"Accept": "application/json"}) + r.raise_for_status() + return r.json() + + +class SubmitServiceAppView(FormView): + template_name = "serviceapps/submit.html" + form_class = ServiceAppSubmitForm + + def get_success_url(self): + return reverse("serviceapps:list") + + def form_valid(self, form): + base = form.cleaned_data["service_url"].rstrip("/") + + try: + meta = _fetch_json(base + "/") + except Exception as e: + form.add_error("service_url", f"Could not fetch '/': {e}") + return self.form_invalid(form) + + missing = [k for k in REQUIRED_TOP_KEYS if k not in meta] + if missing: + form.add_error("service_url", + f"Missing keys: {', '.join(missing)}") + return self.form_invalid(form) + + try: + status_json = _fetch_json(base + "/status") + except Exception as e: + form.add_error("service_url", + f"Could not fetch '/status': {e}") + return self.form_invalid(form) + + last_status = ("ok" if status_json.get("status") == "ok" + else "unavailable") + + sa, _ = ServiceApp.objects.get_or_create(service_url=base) + sa.metadata = meta + sa.display_name = meta.get("name", "") + sa.description = meta.get("description", "") + sa.author = meta.get("author", "") + sa.version = meta.get("version", "") + sa.last_status = last_status + sa.last_checked = timezone.now() + sa.is_active = True + sa.save() + + return super().form_valid(form) + + +class ServiceAppListView(ListView): + model = ServiceApp + template_name = "serviceapps/list.html" + context_object_name = "services" + + def get_queryset(self): + qs = ServiceApp.objects.filter(is_active=True) + health = self.request.GET.get("health") + if health: + qs = qs.filter(last_status=health) + q = self.request.GET.get("q") + if q: + qs = qs.filter(display_name__icontains=q) + return qs + + +class ServiceAppDetailView(DetailView): + model = ServiceApp + template_name = "serviceapps/detail.html" + + def get_queryset(self): + return ServiceApp.objects.filter(is_active=True) + + +def service_apps_config(request): + qs = ServiceApp.objects.filter(is_active=True, last_status="ok") + return JsonResponse([sa.as_app_definition() for sa in qs], safe=False) diff --git a/settings/base.py b/settings/base.py index 839570b0b..c982cb382 100755 --- a/settings/base.py +++ b/settings/base.py @@ -85,6 +85,7 @@ 'help', 'backend', 'download', + 'serviceapps', 'appstore' # this must be included to find root templates ) diff --git a/urls.py b/urls.py index 4adfc6adc..5ea5948ad 100755 --- a/urls.py +++ b/urls.py @@ -23,6 +23,7 @@ re_path(r'^users/', include('users.urls')), re_path(r'^help/', include('help.urls')), re_path(r'^backend/', include('backend.urls')), + re_path(r'^', include('serviceapps.urls')), ] # If DJANGO_STATIC_AND_MEDIA then have Django serve