Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ urllib3
Whoosh==2.7.4
django-haystack==3.3.0
Pillow
requests
gevent

14 changes: 14 additions & 0 deletions serviceapps/__init__.py
Original file line number Diff line number Diff line change
@@ -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]
10 changes: 10 additions & 0 deletions serviceapps/admin.py
Original file line number Diff line number Diff line change
@@ -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")
7 changes: 7 additions & 0 deletions serviceapps/apps.py
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions serviceapps/forms.py
Original file line number Diff line number Diff line change
@@ -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"
)
Empty file.
Empty file.
32 changes: 32 additions & 0 deletions serviceapps/management/commands/check_service_apps.py
Original file line number Diff line number Diff line change
@@ -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}")
36 changes: 36 additions & 0 deletions serviceapps/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
Empty file.
32 changes: 32 additions & 0 deletions serviceapps/models.py
Original file line number Diff line number Diff line change
@@ -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}
29 changes: 29 additions & 0 deletions serviceapps/templates/serviceapps/detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %} - {{ object.display_name }}{% endblock %}
{% block content %}
<h1>{{ object.display_name }}</h1>
<p>{{ object.description }}</p>

<dl>
<dt>Service URL</dt>
<dd><a href="{{ object.service_url }}" target="_blank" rel="noopener noreferrer">{{ object.service_url }}</a></dd>
<dt>Author</dt>
<dd>{{ object.author }}</dd>
<dt>Version</dt>
<dd>{{ object.version }}</dd>
<dt>Status</dt>
<dd>{{ object.last_status }}</dd>
</dl>

{% if object.metadata.parameters %}
<h2>Parameters</h2>
<pre>{{ object.metadata.parameters }}</pre>
{% endif %}

{% if object.metadata.cyWebActions %}
<h2>Actions</h2>
<pre>{{ object.metadata.cyWebActions }}</pre>
{% endif %}

<p><a href="{% url 'serviceapps:config' %}" target="_blank">View JSON Config</a></p>
{% endblock %}
27 changes: 27 additions & 0 deletions serviceapps/templates/serviceapps/list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %} - Service Apps{% endblock %}
{% block content %}
<h1>Service Apps</h1>

<form method="get">
<input type="text" name="q" placeholder="Search..." value="{{ request.GET.q }}">
<select name="health">
<option value="">Any</option>
<option value="ok" {% if request.GET.health == "ok" %}selected{% endif %}>OK</option>
<option value="unavailable" {% if request.GET.health == "unavailable" %}selected{% endif %}>Unavailable</option>
<option value="error" {% if request.GET.health == "error" %}selected{% endif %}>Error</option>
</select>
<button type="submit" class="btn">Filter</button>
</form>

<ul>
{% for s in services %}
<li>
<a href="{% url 'serviceapps:detail' s.pk %}">{{ s.display_name|default:s.service_url }}</a>
- <em>{{ s.last_status }}</em>
</li>
{% empty %}
<li>No Service Apps yet.</li>
{% endfor %}
</ul>
{% endblock %}
11 changes: 11 additions & 0 deletions serviceapps/templates/serviceapps/submit.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %} - Submit a Service App{% endblock %}
{% block content %}
<h1>Submit a Service App</h1>
<p>Enter the base URL of your Cytoscape Web Service App.</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Validate &amp; Save</button>
</form>
{% endblock %}
173 changes: 173 additions & 0 deletions serviceapps/tests.py
Original file line number Diff line number Diff line change
@@ -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)
Loading