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