diff --git a/.github/workflows/medcat-demo-app_build.yml b/.github/workflows/medcat-demo-app_build.yml index aca64fcbc..532c25b22 100644 --- a/.github/workflows/medcat-demo-app_build.yml +++ b/.github/workflows/medcat-demo-app_build.yml @@ -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 diff --git a/medcat-demo-app/docker-compose-test.yml b/medcat-demo-app/docker-compose-test.yml index fbb82a6d8..6b0882cd0 100644 --- a/medcat-demo-app/docker-compose-test.yml +++ b/medcat-demo-app/docker-compose-test.yml @@ -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: diff --git a/medcat-demo-app/docker-compose.yml b/medcat-demo-app/docker-compose.yml index 60a392990..d75168e24 100644 --- a/medcat-demo-app/docker-compose.yml +++ b/medcat-demo-app/docker-compose.yml @@ -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 diff --git a/medcat-demo-app/webapp/Dockerfile b/medcat-demo-app/webapp/Dockerfile index 4ce1c9ecf..dce608095 100644 --- a/medcat-demo-app/webapp/Dockerfile +++ b/medcat-demo-app/webapp/Dockerfile @@ -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 @@ -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 \ @@ -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 diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 7cce1297b..f936542dc 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -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) @@ -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( + '
' + ' ' + '' + '' + '
', + 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) diff --git a/medcat-demo-app/webapp/demo/decorators.py b/medcat-demo-app/webapp/demo/decorators.py new file mode 100644 index 000000000..5de46c3d7 --- /dev/null +++ b/medcat-demo-app/webapp/demo/decorators.py @@ -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'} + }) +""" diff --git a/medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py b/medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py new file mode 100644 index 000000000..ddcfedc10 --- /dev/null +++ b/medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py @@ -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), + ), + ] diff --git a/medcat-demo-app/webapp/demo/migrations/0004_alter_apikey_expires_at.py b/medcat-demo-app/webapp/demo/migrations/0004_alter_apikey_expires_at.py new file mode 100644 index 000000000..45f8298c9 --- /dev/null +++ b/medcat-demo-app/webapp/demo/migrations/0004_alter_apikey_expires_at.py @@ -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), + ), + ] diff --git a/medcat-demo-app/webapp/demo/models.py b/medcat-demo-app/webapp/demo/models.py index da2c8aa5d..ee0793291 100644 --- a/medcat-demo-app/webapp/demo/models.py +++ b/medcat-demo-app/webapp/demo/models.py @@ -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) @@ -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})" diff --git a/medcat-demo-app/webapp/demo/templates/umls_api_key_entry.html b/medcat-demo-app/webapp/demo/templates/umls_api_key_entry.html index 867790c71..1ea774b7d 100644 --- a/medcat-demo-app/webapp/demo/templates/umls_api_key_entry.html +++ b/medcat-demo-app/webapp/demo/templates/umls_api_key_entry.html @@ -46,4 +46,24 @@
Please enter your UMLS API key to verify your license:
You can find your API key by logging into your UMLS account and visiting your UMLS Profile.

+
+
+
+
+ +
+
+
+
+ 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 open-resources@cogstack.org with proof + of an ongoing application. + We will subsequently be able to provide you access to the models. +
+
+
+
{% endblock %} diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index a884378e0..cf374b2df 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -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"), ] diff --git a/medcat-demo-app/webapp/demo/views.py b/medcat-demo-app/webapp/demo/views.py index 5b663eabb..5f87cb711 100644 --- a/medcat-demo-app/webapp/demo/views.py +++ b/medcat-demo-app/webapp/demo/views.py @@ -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' @@ -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): diff --git a/medcat-demo-app/webapp/webapp/settings.py b/medcat-demo-app/webapp/webapp/settings.py index cd68965dc..795018569 100644 --- a/medcat-demo-app/webapp/webapp/settings.py +++ b/medcat-demo-app/webapp/webapp/settings.py @@ -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/