Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
955105e
CU-869b855n0: Add models for questionnaire
mart-r Nov 20, 2025
171eeb6
CU-869b855n0: Add endpoints for questionnaire
mart-r Nov 20, 2025
0fc11b5
CU-869b855n0: Use questionnaire endpoints
mart-r Nov 20, 2025
31f4770
CU-869b855n0: Add decorator for requiring valid API key
mart-r Nov 20, 2025
3799409
CU-869b855n0: Add relevant admin bits to API keys and questions
mart-r Nov 20, 2025
0cdc4e8
CU-869b855n0: Add API-key requiring endpoint
mart-r Nov 20, 2025
6310810
Fix typo
mart-r Nov 20, 2025
36c9bcf
Fix another few typos
mart-r Nov 20, 2025
56eacae
CU-869b855n0: Add UI for questionnaire
mart-r Nov 20, 2025
b501a3a
CU-869b855n0: Change entry point for questionnaire
mart-r Nov 20, 2025
674e61d
CU-869b855n0: Unify registration of models and their admin stuff
mart-r Nov 20, 2025
d7ffe54
CU-869b855n0: Move migrations to startup instead of build time
mart-r Nov 20, 2025
290883a
CU-869b855n0: Collect static at Dockerfile time
mart-r Nov 20, 2025
b0eb578
CU-869b855n0: Update migrations
mart-r Nov 20, 2025
7f49695
CU-869b855n0: Update / fix startup commnad
mart-r Nov 20, 2025
a0c73e3
CU-869b855n0: Remove collectstatic from CMD since it's done at build …
mart-r Nov 20, 2025
d5b59b2
CU-869b855n0: Fix endpoint from questionnaire to submit
mart-r Nov 20, 2025
f5199f8
CU-869b855n0: Fix another endpoint typo
mart-r Nov 20, 2025
fdf0082
CU-869b855n0: Fix yet another endpoint typo
mart-r Nov 20, 2025
58e2ec0
CU-869b855n0: Fix callback URL
mart-r Nov 20, 2025
2a50482
CU-869b855n0: Update template for better accuracy
mart-r Nov 20, 2025
2f5206f
CU-869b855n0: Fix URL in setup for callback endpoint
mart-r Nov 20, 2025
49bc6c5
CU-869b855n0: Add medvat version to more contexts
mart-r Nov 20, 2025
6a095f4
CU-869b855n0: Add medcat version to more contexts (in questionnaire)
mart-r Nov 20, 2025
4fe7f58
CU-869b855n0: Make number of questions dynamic
mart-r Nov 20, 2025
2b58745
CU-869b855n0: Add base template to quiz
mart-r Nov 20, 2025
c685911
CU-869b855n0: Fix URL in template again
mart-r Nov 20, 2025
363f50e
CU-869b855n0: Add base template to cooldown template
mart-r Nov 20, 2025
dc0c516
CU-869b855n0: Add base template to error template
mart-r Nov 20, 2025
55b4c11
CU-869b855n0: Some whitespace changes
mart-r Nov 20, 2025
16ebb2b
CU-869b855n0: Fix quiz template
mart-r Nov 21, 2025
f9723fc
CU-869b855n0: Fix URL for retry on cooldown
mart-r Nov 21, 2025
20b0ac5
CU-869b855n0: Add link to questionnaire (and UMLS license) to API key…
mart-r Nov 21, 2025
64d6e78
CU-869b855n0: Update template to include details on expiry and attempts
mart-r Nov 21, 2025
71839b2
CU-869b855n0: Fix missing medcat version context
mart-r Nov 21, 2025
568ebb5
CU-869b855n0: Centralise cooldown time
mart-r Nov 21, 2025
25dab53
CU-869b855n0: Fix cooldown (hopefully)
mart-r Nov 21, 2025
726c351
CU-869b855n0: Avoid treating a migration with error in its name as an…
mart-r Nov 21, 2025
ccf0f27
CU-869b855n0: Avoid mention of API key to lower confusion
mart-r Nov 21, 2025
76b7e36
CU-869b855n0: Update messaging for no API-key activation
mart-r Nov 24, 2025
976c130
CU-869b855n0: Remove question and attempt models
mart-r Nov 24, 2025
ae3b3e7
CU-869b855n0: Remove questionnaire endpoints
mart-r Nov 24, 2025
b909540
CU-869b855n0: Fix issue with show lib version
mart-r Nov 24, 2025
f470e44
CU-869b855n0: Allow (by default) 14 days for API key cooldown
mart-r Nov 24, 2025
02e49c2
CU-869b855n0: Add permission to manually add API keys
mart-r Nov 24, 2025
0b722bf
CU-869b855n0: Allow changing expiry date of API key
mart-r Nov 24, 2025
6393910
CU-869b855n0: Update manual API callback URL
mart-r Nov 24, 2025
fedf1ad
CU-869b855n0: Add API key link
mart-r Nov 24, 2025
d309f38
CU-869b855n0: Update admin view with correct URL
mart-r Nov 24, 2025
af29da5
CU-869b855n0: Some formatting changes to dockerfile
mart-r Nov 24, 2025
59ffb95
CU-869b855n0: Fix copy link
mart-r Nov 24, 2025
9e6e0d0
CU-869b855n0: Remove mention of questionnaire
mart-r Nov 24, 2025
acb81e8
CU-869b855n0: Add default value for expiry of API keys
mart-r Nov 24, 2025
ebd4286
CU-869b855n0: Update expiry default with picklable method
mart-r Nov 24, 2025
fd20cab
CU-869b855n0: Add migration for default expiry
mart-r Nov 24, 2025
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
3 changes: 2 additions & 1 deletion .github/workflows/medcat-demo-app_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ jobs:
- name: Check container logs for errors
run: |
docker-compose logs medcatweb
docker-compose logs medcatweb | grep -i 'error' && exit 1 || true
# NOTE: ignore line "Applying auth.0007_alter_validators_add_error_messages" - not an error
docker-compose logs medcatweb | grep -v "Applying auth.0007_alter_validators_add_error_messages" | grep -i 'error' && exit 1 || true

- name: Tear down
run: docker-compose -f docker-compose-test.yml down
6 changes: 4 additions & 2 deletions medcat-demo-app/docker-compose-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ services:
args:
REINSTALL_CORE_FROM_LOCAL: "true"
command: >
bash -c "/etc/init.d/cron start &&
python /webapp/manage.py runserver 0.0.0.0:8000"
bash -c "
python manage.py migrate --noinput &&
/etc/init.d/cron start &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- ../medcat-v2/tests/resources:/webapp/models
ports:
Expand Down
6 changes: 4 additions & 2 deletions medcat-demo-app/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ services:
network: host
context: ./webapp
command: >
bash -c "/etc/init.d/cron start &&
python /webapp/manage.py runserver 0.0.0.0:8000"
bash -c "
python manage.py migrate --noinput &&
/etc/init.d/cron start &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- ./webapp/data:/webapp/data
- ./webapp/db:/webapp/db
Expand Down
12 changes: 4 additions & 8 deletions medcat-demo-app/webapp/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1: Build stage (dependencies and compilation)
FROM python:3.12-slim as build
FROM python:3.12-slim AS build

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

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

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

# Run migrations and collect static (could be in entrypoint script)
RUN python manage.py makemigrations && \
python manage.py makemigrations demo && \
python manage.py migrate && \
python manage.py migrate demo && \
python manage.py collectstatic --noinput
# Run collect static (could be in entrypoint script)
RUN python manage.py collectstatic --noinput
56 changes: 55 additions & 1 deletion medcat-demo-app/webapp/demo/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from django.contrib import admin
from django.utils.html import format_html
from django.conf import settings

from .models import *

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


class UploadedTextAdmin(admin.ModelAdmin):
model = UploadedText
actions = [remove_text]


class APIKeyAdmin(admin.ModelAdmin):
list_display = ('key_short', 'identifier', 'created_at', 'expires_at', 'is_active', 'is_expired')
list_filter = ('is_active', 'created_at', 'expires_at')
search_fields = ('key', 'identifier')

def get_readonly_fields(self, request, obj=None):
if obj: # Editing an existing object
return ('key', 'created_at', 'api_key_link', 'expires_at')
else: # Creating a new object
return ('key', 'created_at', 'api_key_link')

def key_short(self, obj):
return f"{obj.key[:10]}..."
key_short.short_description = 'API Key'

def is_expired(self, obj):
from django.utils import timezone
return obj.expires_at < timezone.now()
is_expired.boolean = True
is_expired.short_description = 'Expired'

def api_key_link(self, obj: APIKey):
if bool(obj.key) and obj.is_active:
current_site = settings.BASE_URL
base_url = f"{current_site}/manual-api-callback/"
callback_url = f"{base_url}?api_key={obj.key}"
unique_id = obj.identifier

formatted = format_html(
'<div style="margin: 10px 0;">'
'<input type="text" value="{}" id="api-url-{}" readonly '
'style="width: 500px; padding: 5px; margin-right: 10px;" /> '
'<button type="button" onclick="'
'navigator.clipboard.writeText(\'{}\').then(function() {{'
' document.getElementById(\'copy-status-{}\').textContent = \'✓ Copied!\';'
' setTimeout(function() {{'
' document.getElementById(\'copy-status-{}\').textContent = \'\';'
' }}, 2000);'
'}});'
'" style="padding: 5px 10px; cursor: pointer;">Copy URL</button>'
'<span id="copy-status-{}" style="margin-left: 10px; color: green;"></span>'
'</div>',
callback_url, unique_id, callback_url, unique_id, unique_id, unique_id
)
return formatted
return "-"
api_key_link.short_description = 'API Key URL'


# Register your models here.
admin.site.register(UploadedText, UploadedTextAdmin)

admin.site.register(APIKey, APIKeyAdmin)
53 changes: 53 additions & 0 deletions medcat-demo-app/webapp/demo/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from functools import wraps
from django.http import JsonResponse
from .models import APIKey


def require_valid_api_key(view_func):
"""
Decorator to protect endpoints with API key authentication

Usage:
@require_valid_api_key
def my_protected_view(request):
# Your view logic
pass
"""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
# Check for API key in header or query parameter
api_key = (
request.headers.get('X-API-Key') or
request.GET.get('api_key') or
request.POST.get('api_key')
)

if not api_key:
return JsonResponse({
'error': 'API key required',
'message': 'Please provide an API key via X-API-Key header or api_key parameter'
}, status=401)

if not APIKey.is_valid(api_key):
return JsonResponse({
'error': 'Invalid or expired API key',
'message': 'Please obtain a valid API key'
}, status=401)

# API key is valid, proceed with the view
return view_func(request, *args, **kwargs)

return wrapper


# Example usage in views.py:
"""
from .decorators import require_valid_api_key

@require_valid_api_key
def protected_endpoint(request):
return JsonResponse({
'message': 'You have access to this protected resource!',
'data': {'example': 'data'}
})
"""
34 changes: 34 additions & 0 deletions medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 3.2.25 on 2025-11-20 22:21

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('demo', '0002_downloader_medcatmodel'),
]

operations = [
migrations.CreateModel(
name='APIKey',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=64, unique=True)),
('identifier', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('is_active', models.BooleanField(default=True)),
],
),
migrations.AlterField(
model_name='downloader',
name='downloaded_file',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='medcatmodel',
name='model_description',
field=models.TextField(max_length=200),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.2.25 on 2025-11-24 16:21

import demo.models
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('demo', '0003_auto_20251120_2221'),
]

operations = [
migrations.AlterField(
model_name='apikey',
name='expires_at',
field=models.DateTimeField(default=demo.models._default_expiry),
),
]
47 changes: 47 additions & 0 deletions medcat-demo-app/webapp/demo/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import secrets

from datetime import timedelta

from django.db import models
from django.core.files.storage import FileSystemStorage
from django.utils import timezone


MODEL_FS = FileSystemStorage(location="/medcat_data")


cooldown_days = 14


# Create your models here.
class UploadedText(models.Model):
text = models.TextField(default="", blank=True)
Expand All @@ -29,3 +37,42 @@ class MedcatModel(models.Model):
model_file = models.FileField(storage=MODEL_FS)
model_display_name = models.CharField(max_length=50)
model_description = models.TextField(max_length=200)


def _default_expiry():
return timezone.now() + timedelta(days=cooldown_days)


class APIKey(models.Model):
"""Temporary API keys for successful completions"""
key = models.CharField(max_length=64, unique=True, db_index=True)
identifier = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(
default=_default_expiry)
is_active = models.BooleanField(default=True)

def save(self, *args, **kwargs):
if not self.key:
self.key = secrets.token_urlsafe(48)
if not self.expires_at:
self.expires_at = timezone.now() + timedelta(days=cooldown_days)
super().save(*args, **kwargs)

@classmethod
def is_valid(cls, key):
"""Check if an API key is valid and not expired"""
try:
api_key = cls.objects.get(key=key, is_active=True)
if api_key.expires_at > timezone.now():
return True
else:
# Mark as inactive if expired
api_key.is_active = False
api_key.save()
return False
except cls.DoesNotExist:
return False

def __str__(self):
return f"{self.key[:10]}... (expires: {self.expires_at})"
20 changes: 20 additions & 0 deletions medcat-demo-app/webapp/demo/templates/umls_api_key_entry.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,24 @@ <h5>Please enter your UMLS API key to verify your license:</h5>
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>.
</p>
</div>
<div class="accordion mb-3" id="noApiKeyAccordion">
<div class="card">
<div class="card-header" id="noApiKeyHeading">
<h5 class="mb-0">
<button class="btn btn-link" type="button" data-toggle="collapse" data-target="#noApiKeyCollapse" aria-expanded="false" aria-controls="noApiKeyCollapse">
<i class="fas fa-question-circle"></i> Cannot obtain an API key...
</button>
</h5>
</div>
<div id="noApiKeyCollapse" class="collapse" aria-labelledby="noApiKeyHeading" data-parent="#noApiKeyAccordion">
<div class="card-body">
If you are unable to obtain an API key due to your application for a UMLS license
being unanswered due to funding constraints in the NIH.
If that is the case, please email us at <tt>open-resources@cogstack.org</tt> with proof
of an ongoing application.
We will subsequently be able to provide you access to the models.
</div>
</div>
</div>
</div>
{% endblock %}
5 changes: 4 additions & 1 deletion medcat-demo-app/webapp/demo/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
path('', show_annotations, name='train_annotations'),
path('auth-callback', validate_umls_user, name='validate-umls-user'),
path('auth-callback-api', validate_umls_api_key, name='validate-umls-api-key'),
path('download-model', download_model, name="download-model")
path('download-model', download_model, name="download-model"),
# manual API callback
path('manual-api-callback/', model_after_api_key,
name="model_after_api_key"),
]
16 changes: 15 additions & 1 deletion medcat-demo-app/webapp/demo/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#from medcat.meta_cat import MetaCAT
from .models import *
from .forms import DownloaderForm, UMLSApiKeyForm
from .decorators import require_valid_api_key
from functools import lru_cache

AUTH_CALLBACK_SERVICE = 'https://medcat.rosalind.kcl.ac.uk/auth-callback'
Expand Down Expand Up @@ -201,11 +202,24 @@ def validate_umls_api_key(request):
'message': f'Error validating API key: {str(e)}'
}

context['medcat_version'] = medcat_version
return render(request, 'umls_user_validation.html', context=context)
else:
form = UMLSApiKeyForm()

return render(request, 'umls_api_key_entry.html', {'form': form})
return render(request, 'umls_api_key_entry.html',
{'form': form, 'medcat_version': medcat_version})


@require_valid_api_key
def model_after_api_key(request):
context = {
'is_valid': True,
'message': f'Manually obtained API key is being used',
'downloader_form': DownloaderForm(MedcatModel.objects.all())
}
context['medcat_version'] = medcat_version
return render(request, 'umls_user_validation.html', context=context)


def download_model(request):
Expand Down
3 changes: 3 additions & 0 deletions medcat-demo-app/webapp/webapp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@

USE_TZ = True

# BASE URL
BASE_URL = os.getenv('BASE_URL', 'http://localhost:80')


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
Expand Down