Skip to content

Commit cb9e985

Browse files
authored
feat(medcat-demo-web-app): CU-869b855n0 Manual model access (#230)
* CU-869b855n0: Add models for questionnaire * CU-869b855n0: Add endpoints for questionnaire * CU-869b855n0: Use questionnaire endpoints * CU-869b855n0: Add decorator for requiring valid API key * CU-869b855n0: Add relevant admin bits to API keys and questions * CU-869b855n0: Add API-key requiring endpoint * Fix typo * Fix another few typos * CU-869b855n0: Add UI for questionnaire * CU-869b855n0: Change entry point for questionnaire * CU-869b855n0: Unify registration of models and their admin stuff * CU-869b855n0: Move migrations to startup instead of build time * CU-869b855n0: Collect static at Dockerfile time * CU-869b855n0: Update migrations * CU-869b855n0: Update / fix startup commnad * CU-869b855n0: Remove collectstatic from CMD since it's done at build time * CU-869b855n0: Fix endpoint from questionnaire to submit * CU-869b855n0: Fix another endpoint typo * CU-869b855n0: Fix yet another endpoint typo * CU-869b855n0: Fix callback URL * CU-869b855n0: Update template for better accuracy * CU-869b855n0: Fix URL in setup for callback endpoint * CU-869b855n0: Add medvat version to more contexts * CU-869b855n0: Add medcat version to more contexts (in questionnaire) * CU-869b855n0: Make number of questions dynamic * CU-869b855n0: Add base template to quiz * CU-869b855n0: Fix URL in template again * CU-869b855n0: Add base template to cooldown template * CU-869b855n0: Add base template to error template * CU-869b855n0: Some whitespace changes * CU-869b855n0: Fix quiz template * CU-869b855n0: Fix URL for retry on cooldown * CU-869b855n0: Add link to questionnaire (and UMLS license) to API key based view * CU-869b855n0: Update template to include details on expiry and attempts * CU-869b855n0: Fix missing medcat version context * CU-869b855n0: Centralise cooldown time * CU-869b855n0: Fix cooldown (hopefully) * CU-869b855n0: Avoid treating a migration with error in its name as an error * CU-869b855n0: Avoid mention of API key to lower confusion * CU-869b855n0: Update messaging for no API-key activation * CU-869b855n0: Remove question and attempt models * CU-869b855n0: Remove questionnaire endpoints * CU-869b855n0: Fix issue with show lib version * CU-869b855n0: Allow (by default) 14 days for API key cooldown * CU-869b855n0: Add permission to manually add API keys * CU-869b855n0: Allow changing expiry date of API key * CU-869b855n0: Update manual API callback URL * CU-869b855n0: Add API key link * CU-869b855n0: Update admin view with correct URL * CU-869b855n0: Some formatting changes to dockerfile * CU-869b855n0: Fix copy link * CU-869b855n0: Remove mention of questionnaire * CU-869b855n0: Add default value for expiry of API keys * CU-869b855n0: Update expiry default with picklable method * CU-869b855n0: Add migration for default expiry * CU-869b855n0: Minor reword
1 parent e2d0940 commit cb9e985

File tree

13 files changed

+263
-16
lines changed

13 files changed

+263
-16
lines changed

.github/workflows/medcat-demo-app_build.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ jobs:
4949
- name: Check container logs for errors
5050
run: |
5151
docker-compose logs medcatweb
52-
docker-compose logs medcatweb | grep -i 'error' && exit 1 || true
52+
# NOTE: ignore line "Applying auth.0007_alter_validators_add_error_messages" - not an error
53+
docker-compose logs medcatweb | grep -v "Applying auth.0007_alter_validators_add_error_messages" | grep -i 'error' && exit 1 || true
5354
5455
- name: Tear down
5556
run: docker-compose -f docker-compose-test.yml down

medcat-demo-app/docker-compose-test.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ services:
77
args:
88
REINSTALL_CORE_FROM_LOCAL: "true"
99
command: >
10-
bash -c "/etc/init.d/cron start &&
11-
python /webapp/manage.py runserver 0.0.0.0:8000"
10+
bash -c "
11+
python manage.py migrate --noinput &&
12+
/etc/init.d/cron start &&
13+
python manage.py runserver 0.0.0.0:8000"
1214
volumes:
1315
- ../medcat-v2/tests/resources:/webapp/models
1416
ports:

medcat-demo-app/docker-compose.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ services:
66
network: host
77
context: ./webapp
88
command: >
9-
bash -c "/etc/init.d/cron start &&
10-
python /webapp/manage.py runserver 0.0.0.0:8000"
9+
bash -c "
10+
python manage.py migrate --noinput &&
11+
/etc/init.d/cron start &&
12+
python manage.py runserver 0.0.0.0:8000"
1113
volumes:
1214
- ./webapp/data:/webapp/data
1315
- ./webapp/db:/webapp/db

medcat-demo-app/webapp/Dockerfile

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Stage 1: Build stage (dependencies and compilation)
2-
FROM python:3.12-slim as build
2+
FROM python:3.12-slim AS build
33

44
# Create the required folders
55
RUN mkdir -p /webapp/models
@@ -37,7 +37,7 @@ RUN if [ "$REINSTALL_CORE_FROM_LOCAL" = "true" ]; then \
3737
RUN python -m spacy download en_core_web_md
3838

3939
# Stage 2: Final (production) image
40-
FROM python:3.12-slim as final
40+
FROM python:3.12-slim AS final
4141

4242
# Install runtime dependencies (you don’t need git in production)
4343
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -62,9 +62,5 @@ WORKDIR /webapp
6262
COPY etc/cron.d/db-backup-cron /etc/cron.d/db-backup-cron
6363
RUN chmod 0644 /etc/cron.d/db-backup-cron && crontab /etc/cron.d/db-backup-cron
6464

65-
# Run migrations and collect static (could be in entrypoint script)
66-
RUN python manage.py makemigrations && \
67-
python manage.py makemigrations demo && \
68-
python manage.py migrate && \
69-
python manage.py migrate demo && \
70-
python manage.py collectstatic --noinput
65+
# Run collect static (could be in entrypoint script)
66+
RUN python manage.py collectstatic --noinput
Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from django.contrib import admin
2+
from django.utils.html import format_html
3+
from django.conf import settings
4+
25
from .models import *
36

47
admin.site.register(Downloader)
@@ -7,10 +10,61 @@
710
def remove_text(modeladmin, request, queryset):
811
UploadedText.objects.all().delete()
912

13+
1014
class UploadedTextAdmin(admin.ModelAdmin):
1115
model = UploadedText
1216
actions = [remove_text]
1317

18+
19+
class APIKeyAdmin(admin.ModelAdmin):
20+
list_display = ('key_short', 'identifier', 'created_at', 'expires_at', 'is_active', 'is_expired')
21+
list_filter = ('is_active', 'created_at', 'expires_at')
22+
search_fields = ('key', 'identifier')
23+
24+
def get_readonly_fields(self, request, obj=None):
25+
if obj: # Editing an existing object
26+
return ('key', 'created_at', 'api_key_link', 'expires_at')
27+
else: # Creating a new object
28+
return ('key', 'created_at', 'api_key_link')
29+
30+
def key_short(self, obj):
31+
return f"{obj.key[:10]}..."
32+
key_short.short_description = 'API Key'
33+
34+
def is_expired(self, obj):
35+
from django.utils import timezone
36+
return obj.expires_at < timezone.now()
37+
is_expired.boolean = True
38+
is_expired.short_description = 'Expired'
39+
40+
def api_key_link(self, obj: APIKey):
41+
if bool(obj.key) and obj.is_active:
42+
current_site = settings.BASE_URL
43+
base_url = f"{current_site}/manual-api-callback/"
44+
callback_url = f"{base_url}?api_key={obj.key}"
45+
unique_id = obj.identifier
46+
47+
formatted = format_html(
48+
'<div style="margin: 10px 0;">'
49+
'<input type="text" value="{}" id="api-url-{}" readonly '
50+
'style="width: 500px; padding: 5px; margin-right: 10px;" /> '
51+
'<button type="button" onclick="'
52+
'navigator.clipboard.writeText(\'{}\').then(function() {{'
53+
' document.getElementById(\'copy-status-{}\').textContent = \'✓ Copied!\';'
54+
' setTimeout(function() {{'
55+
' document.getElementById(\'copy-status-{}\').textContent = \'\';'
56+
' }}, 2000);'
57+
'}});'
58+
'" style="padding: 5px 10px; cursor: pointer;">Copy URL</button>'
59+
'<span id="copy-status-{}" style="margin-left: 10px; color: green;"></span>'
60+
'</div>',
61+
callback_url, unique_id, callback_url, unique_id, unique_id, unique_id
62+
)
63+
return formatted
64+
return "-"
65+
api_key_link.short_description = 'API Key URL'
66+
67+
1468
# Register your models here.
1569
admin.site.register(UploadedText, UploadedTextAdmin)
16-
70+
admin.site.register(APIKey, APIKeyAdmin)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from functools import wraps
2+
from django.http import JsonResponse
3+
from .models import APIKey
4+
5+
6+
def require_valid_api_key(view_func):
7+
"""
8+
Decorator to protect endpoints with API key authentication
9+
10+
Usage:
11+
@require_valid_api_key
12+
def my_protected_view(request):
13+
# Your view logic
14+
pass
15+
"""
16+
@wraps(view_func)
17+
def wrapper(request, *args, **kwargs):
18+
# Check for API key in header or query parameter
19+
api_key = (
20+
request.headers.get('X-API-Key') or
21+
request.GET.get('api_key') or
22+
request.POST.get('api_key')
23+
)
24+
25+
if not api_key:
26+
return JsonResponse({
27+
'error': 'API key required',
28+
'message': 'Please provide an API key via X-API-Key header or api_key parameter'
29+
}, status=401)
30+
31+
if not APIKey.is_valid(api_key):
32+
return JsonResponse({
33+
'error': 'Invalid or expired API key',
34+
'message': 'Please obtain a valid API key'
35+
}, status=401)
36+
37+
# API key is valid, proceed with the view
38+
return view_func(request, *args, **kwargs)
39+
40+
return wrapper
41+
42+
43+
# Example usage in views.py:
44+
"""
45+
from .decorators import require_valid_api_key
46+
47+
@require_valid_api_key
48+
def protected_endpoint(request):
49+
return JsonResponse({
50+
'message': 'You have access to this protected resource!',
51+
'data': {'example': 'data'}
52+
})
53+
"""
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 3.2.25 on 2025-11-20 22:21
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('demo', '0002_downloader_medcatmodel'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='APIKey',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('key', models.CharField(db_index=True, max_length=64, unique=True)),
18+
('identifier', models.CharField(max_length=255)),
19+
('created_at', models.DateTimeField(auto_now_add=True)),
20+
('expires_at', models.DateTimeField()),
21+
('is_active', models.BooleanField(default=True)),
22+
],
23+
),
24+
migrations.AlterField(
25+
model_name='downloader',
26+
name='downloaded_file',
27+
field=models.CharField(max_length=100),
28+
),
29+
migrations.AlterField(
30+
model_name='medcatmodel',
31+
name='model_description',
32+
field=models.TextField(max_length=200),
33+
),
34+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 3.2.25 on 2025-11-24 16:21
2+
3+
import demo.models
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('demo', '0003_auto_20251120_2221'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='apikey',
16+
name='expires_at',
17+
field=models.DateTimeField(default=demo.models._default_expiry),
18+
),
19+
]

medcat-demo-app/webapp/demo/models.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import secrets
2+
3+
from datetime import timedelta
4+
15
from django.db import models
26
from django.core.files.storage import FileSystemStorage
7+
from django.utils import timezone
38

49

510
MODEL_FS = FileSystemStorage(location="/medcat_data")
611

712

13+
cooldown_days = 14
14+
15+
816
# Create your models here.
917
class UploadedText(models.Model):
1018
text = models.TextField(default="", blank=True)
@@ -29,3 +37,42 @@ class MedcatModel(models.Model):
2937
model_file = models.FileField(storage=MODEL_FS)
3038
model_display_name = models.CharField(max_length=50)
3139
model_description = models.TextField(max_length=200)
40+
41+
42+
def _default_expiry():
43+
return timezone.now() + timedelta(days=cooldown_days)
44+
45+
46+
class APIKey(models.Model):
47+
"""Temporary API keys for successful completions"""
48+
key = models.CharField(max_length=64, unique=True, db_index=True)
49+
identifier = models.CharField(max_length=255)
50+
created_at = models.DateTimeField(auto_now_add=True)
51+
expires_at = models.DateTimeField(
52+
default=_default_expiry)
53+
is_active = models.BooleanField(default=True)
54+
55+
def save(self, *args, **kwargs):
56+
if not self.key:
57+
self.key = secrets.token_urlsafe(48)
58+
if not self.expires_at:
59+
self.expires_at = timezone.now() + timedelta(days=cooldown_days)
60+
super().save(*args, **kwargs)
61+
62+
@classmethod
63+
def is_valid(cls, key):
64+
"""Check if an API key is valid and not expired"""
65+
try:
66+
api_key = cls.objects.get(key=key, is_active=True)
67+
if api_key.expires_at > timezone.now():
68+
return True
69+
else:
70+
# Mark as inactive if expired
71+
api_key.is_active = False
72+
api_key.save()
73+
return False
74+
except cls.DoesNotExist:
75+
return False
76+
77+
def __str__(self):
78+
return f"{self.key[:10]}... (expires: {self.expires_at})"

medcat-demo-app/webapp/demo/templates/umls_api_key_entry.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,23 @@ <h5>Please enter your UMLS API key to verify your license:</h5>
4646
You can find your API key by logging into your UMLS account and visiting your <a href="https://uts.nlm.nih.gov/uts/profile" target="_blank">UMLS Profile</a>.
4747
</p>
4848
</div>
49+
<div class="accordion mb-3" id="noApiKeyAccordion">
50+
<div class="card">
51+
<div class="card-header" id="noApiKeyHeading">
52+
<h5 class="mb-0">
53+
<button class="btn btn-link" type="button" data-toggle="collapse" data-target="#noApiKeyCollapse" aria-expanded="false" aria-controls="noApiKeyCollapse">
54+
<i class="fas fa-question-circle"></i> Cannot obtain an API key...
55+
</button>
56+
</h5>
57+
</div>
58+
<div id="noApiKeyCollapse" class="collapse" aria-labelledby="noApiKeyHeading" data-parent="#noApiKeyAccordion">
59+
<div class="card-body">
60+
If you are unable to obtain an API key due to your application for a UMLS license being unanswered,
61+
please email us at <tt>open-resources@cogstack.org</tt> with proof
62+
of an ongoing application.
63+
We will subsequently be able to provide you access to the models.
64+
</div>
65+
</div>
66+
</div>
67+
</div>
4968
{% endblock %}

0 commit comments

Comments
 (0)