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..d29f1bf56 100644 --- a/medcat-demo-app/webapp/Dockerfile +++ b/medcat-demo-app/webapp/Dockerfile @@ -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..5ebcecec2 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -7,10 +7,53 @@ def remove_text(modeladmin, request, queryset): UploadedText.objects.all().delete() + class UploadedTextAdmin(admin.ModelAdmin): model = UploadedText actions = [remove_text] +class QuestionAdmin(admin.ModelAdmin): + list_display = ('question_text_short', 'correct_answer', 'is_active') + list_filter = ('is_active', 'correct_answer') + search_fields = ('question_text',) + + def question_text_short(self, obj): + return obj.question_text[:50] + '...' if len(obj.question_text) > 50 else obj.question_text + question_text_short.short_description = 'Question' + + +class UserAttemptAdmin(admin.ModelAdmin): + list_display = ('identifier', 'attempted_at', 'passed') + list_filter = ('passed', 'attempted_at') + search_fields = ('identifier',) + readonly_fields = ('identifier', 'attempted_at', 'passed') + + def has_add_permission(self, request): + return False + + +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') + readonly_fields = ('key', 'created_at', 'expires_at') + + 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 has_add_permission(self, request): + return False + + # Register your models here. admin.site.register(UploadedText, UploadedTextAdmin) - +admin.site.register(Question, QuestionAdmin) +admin.site.register(UserAttempt, UserAttemptAdmin) +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..be71476f0 --- /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 complete the questionnaire to 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..8d354a736 --- /dev/null +++ b/medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py @@ -0,0 +1,59 @@ +# 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.CreateModel( + name='Question', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question_text', models.TextField()), + ('option_a', models.CharField(max_length=255)), + ('option_b', models.CharField(max_length=255)), + ('option_c', models.CharField(max_length=255)), + ('option_d', models.CharField(max_length=255)), + ('correct_answer', models.CharField(choices=[('a', 'A'), ('b', 'B'), ('c', 'C'), ('d', 'D')], max_length=1)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='UserAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('identifier', models.CharField(db_index=True, max_length=255)), + ('attempted_at', models.DateTimeField(auto_now_add=True)), + ('passed', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['-attempted_at'], + }, + ), + 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/models.py b/medcat-demo-app/webapp/demo/models.py index da2c8aa5d..21942e8fe 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_minutes = 30 + + # Create your models here. class UploadedText(models.Model): text = models.TextField(default="", blank=True) @@ -29,3 +37,91 @@ 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) + + +class Question(models.Model): + """Multiple choice questions for the questionnaire""" + question_text = models.TextField() + option_a = models.CharField(max_length=255) + option_b = models.CharField(max_length=255) + option_c = models.CharField(max_length=255) + option_d = models.CharField(max_length=255) + correct_answer = models.CharField( + max_length=1, + choices=[('a', 'A'), ('b', 'B'), ('c', 'C'), ('d', 'D')] + ) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.question_text[:50] + + +class UserAttempt(models.Model): + """Track user attempts to prevent brute-forcing""" + identifier = models.CharField(max_length=255, db_index=True) # IP or user ID + attempted_at = models.DateTimeField(auto_now_add=True) + passed = models.BooleanField(default=False) + + class Meta: + ordering = ['-attempted_at'] + + @classmethod + def can_attempt(cls, identifier): + """Check if user can attempt the questionnaire""" + thirty_mins_ago = timezone.now() - timedelta(minutes=cooldown_minutes) + recent_failed = cls.objects.filter( + identifier=identifier, + attempted_at__gte=thirty_mins_ago, + passed=False + ).exists() + return not recent_failed + + @classmethod + def get_cooldown_remaining(cls, identifier): + """Get remaining cooldown time in seconds""" + thirty_mins_ago = timezone.now() - timedelta(minutes=cooldown_minutes) + recent_failed = cls.objects.filter( + identifier=identifier, + attempted_at__gte=thirty_mins_ago, + passed=False + ).first() + + if recent_failed: + time_passed = timezone.now() - recent_failed.attempted_at + remaining = timedelta(minutes=cooldown_minutes) - time_passed + return max(0, int(remaining.total_seconds())) + return 0 + + +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() + 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(minutes=cooldown_minutes) + 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/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py new file mode 100644 index 000000000..408c8cfe8 --- /dev/null +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -0,0 +1,172 @@ +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt +from django.db import transaction +from django.shortcuts import render +import json +import random +import os + +from medcat import __version__ as medcat_version + +from .models import Question, UserAttempt, APIKey, cooldown_minutes + + +def get_client_identifier(request): + """Get a unique identifier for the client (IP-based)""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +def get_number_of_questions() -> int: + return int(os.environ.get("MEDCAT_DEMO_MIN_QUESTIONS", 5)) + + +@csrf_exempt +@require_http_methods(["GET"]) +def get_questionnaire(request): + """Get N random questions from the pool""" + identifier = get_client_identifier(request) + + # Check if user can attempt + if not UserAttempt.can_attempt(identifier): + cooldown = UserAttempt.get_cooldown_remaining(identifier) + return render(request, 'questionnaire/cooldown.html', { + 'cooldown_seconds': cooldown, + 'cooldown_minutes': cooldown_minutes, + 'medcat_version': medcat_version, + }) + + + # Get random questions (adjust N as needed) + N = get_number_of_questions() + questions = list(Question.objects.filter(is_active=True)) + + if len(questions) < N: + return render(request, 'questionnaire/error.html', { + 'error': 'Not enough questions available. Please contact the administrator.', + 'medcat_version': medcat_version, + }) + + + selected_questions = random.sample(questions, N) + + # Format questions for response + questions_data = [] + for q in selected_questions: + questions_data.append({ + 'id': q.id, + 'question': q.question_text, + 'options': { + 'a': q.option_a, + 'b': q.option_b, + 'c': q.option_c, + 'd': q.option_d, + } + }) + + return render(request, 'questionnaire/quiz.html', { + 'questions': selected_questions, + 'total': N, + 'medcat_version': medcat_version, + }) + + + +@csrf_exempt +@require_http_methods(["POST"]) +def submit_questionnaire(request): + """Validate answers and generate API key if all correct""" + identifier = get_client_identifier(request) + + # Check if user can attempt + if not UserAttempt.can_attempt(identifier): + cooldown = UserAttempt.get_cooldown_remaining(identifier) + return JsonResponse({ + 'error': 'Too many failed attempts', + 'cooldown_seconds': cooldown + }, status=429) + + try: + data = json.loads(request.body) + answers = data.get('answers', {}) # Expected format: {"question_id": "a", ...} + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + + if not answers: + return JsonResponse({'error': 'No answers provided'}, status=400) + + # Validate all answers + all_correct = True + for question_id, user_answer in answers.items(): + try: + question = Question.objects.get(id=question_id, is_active=True) + if question.correct_answer != user_answer.lower(): + all_correct = False + break + except Question.DoesNotExist: + return JsonResponse({ + 'error': f'Invalid question ID: {question_id}' + }, status=400) + + # Record attempt + with transaction.atomic(): + attempt = UserAttempt.objects.create( + identifier=identifier, + passed=all_correct + ) + + if all_correct: + # Generate API key + api_key = APIKey.objects.create(identifier=identifier) + + # Build the full URL to the secret endpoint + scheme = 'https' if request.is_secure() else 'http' + host = request.get_host() + secret_url = f"{scheme}://{host}/callback-after-questionnaire/?api_key={api_key.key}" + + return JsonResponse({ + 'success': True, + 'message': 'All answers correct! API key generated.', + 'api_key': api_key.key, + 'expires_at': api_key.expires_at.isoformat(), + 'valid_for_minutes': cooldown_minutes, + 'secret_link': secret_url + }) + + else: + return JsonResponse({ + 'success': False, + 'message': 'Some answers were incorrect. ' + f'Try again in {cooldown_minutes} minutes.', + 'cooldown_seconds': cooldown_minutes * 60, + }, status=403) + + +# Optional: Endpoint to check API key validity +@csrf_exempt +@require_http_methods(["GET"]) +def check_api_key(request): + """Check if an API key is valid""" + api_key = request.headers.get('X-API-Key') or request.GET.get('api_key') + + if not api_key: + return JsonResponse({'error': 'No API key provided'}, status=400) + + is_valid = APIKey.is_valid(api_key) + + if is_valid: + key_obj = APIKey.objects.get(key=api_key) + return JsonResponse({ + 'valid': True, + 'expires_at': key_obj.expires_at.isoformat() + }) + else: + return JsonResponse({ + 'valid': False, + 'message': 'API key is invalid or expired' + }, status=401) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html new file mode 100644 index 000000000..4477e2153 --- /dev/null +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html @@ -0,0 +1,152 @@ +{% extends "base.html" %} + +{% block style %} + +{% endblock %} + +{% block body %} +
+
+
⏱️
+

Cooldown Period

+

+ You've attempted the questionnaire with incorrect answers. + To prevent brute-forcing, you must wait before trying again. +

+ +
+
Time Remaining
+
{{ cooldown_minutes }}:00
+
+ + Try Again +
+
+{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/error.html b/medcat-demo-app/webapp/demo/templates/questionnaire/error.html new file mode 100644 index 000000000..6abeaaebe --- /dev/null +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/error.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block style %} + +{% endblock %} + +{% block body %} +
+
+
⚠️
+

Oops!

+

We encountered an error while loading the questionnaire.

+ +
+ {{ error }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html new file mode 100644 index 000000000..4d3797e5d --- /dev/null +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -0,0 +1,315 @@ +{% extends "base.html" %} + +{% block style %} + +{% endblock %} + +{% block body %} +
+
+

🎯 Knowledge Challenge

+

Answer all {{ total }} questions correctly to receive your API key. You have one attempt every 30 minutes.

+ +
+ {% csrf_token %} + {% for question in questions %} +
+
Question {{ forloop.counter }} of {{ total }}
+
{{ question.question_text }}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {% endfor %} + + +
+ +
+

⏳ Checking your answers...

+
+ +
+
+
+{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file 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..e8ce932fb 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,30 @@
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 NIH, you still need to accept their + license. + Once you've read the license agreement you can fill in + this questionnaire to verify that you + have read and understood the license. + +

+ PS: All answers in the questionnaire need to be correct. + You have 1 attempt in every 30 minutes. + An API key is valid for 30 minutes. +

+
+
+
+
{% endblock %} diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index a884378e0..7db71d3e2 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -1,10 +1,23 @@ from django.contrib import admin from django.urls import path from .views import * +from .questionnaire import ( + get_client_identifier, get_questionnaire, submit_questionnaire, check_api_key) urlpatterns = [ 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"), + # questionnaire + path('umls-license-questionnaire/get-client-id', get_client_identifier, + name="get-client-identifier"), + path('umls-license-questionnaire/', get_questionnaire, + name="get-questionnaire"), + path('umls-license-questionnaire/submit-questionnaire', submit_questionnaire, + name="submit-questionnaire"), + path('umls-license-questionnaire/check-api-key', check_api_key, + name="check-api-key"), + path('callback-after-questionnaire/', 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..03b65df86 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,13 +202,26 @@ 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() + context['medcat_version'] = medcat_version return render(request, 'umls_api_key_entry.html', {'form': form}) +@require_valid_api_key +def model_after_api_key(request): + context = { + 'is_valid': True, + 'message': f'Questionnaire based 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): if request.method == 'POST': downloader_form = DownloaderForm(MedcatModel.objects.all(), request.POST)