From 955105e420a82d1a706ad466afcce26875b71ce1 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 16:38:18 +0000 Subject: [PATCH 01/56] CU-869b855n0: Add models for questionnaire --- medcat-demo-app/webapp/demo/models.py | 93 +++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/medcat-demo-app/webapp/demo/models.py b/medcat-demo-app/webapp/demo/models.py index da2c8aa5d..d1b5cef45 100644 --- a/medcat-demo-app/webapp/demo/models.py +++ b/medcat-demo-app/webapp/demo/models.py @@ -1,5 +1,10 @@ +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") @@ -29,3 +34,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=30) + 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=30) + 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=30) - 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=30) + 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})" From 171eeb6060f95eae4dad42532ba35945b3fb8b69 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 16:38:38 +0000 Subject: [PATCH 02/56] CU-869b855n0: Add endpoints for questionnaire --- medcat-demo-app/webapp/demo/questionnaire.py | 152 +++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 medcat-demo-app/webapp/demo/questionnaire.py diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py new file mode 100644 index 000000000..aade82adc --- /dev/null +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -0,0 +1,152 @@ +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 +import json +import random + +from .models import Question, UserAttempt, APIKey + + +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 + + +@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 JsonResponse({ + 'error': 'Too many failed attempts', + 'cooldown_seconds': cooldown, + 'message': f'Please wait {cooldown // 60} minutes before trying again' + }, status=429) + + # Get random questions (adjust N as needed) + N = 5 # Number of questions to ask + questions = list(Question.objects.filter(is_active=True)) + + if len(questions) < N: + return JsonResponse({ + 'error': 'Not enough questions available' + }, status=500) + + 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 JsonResponse({ + 'questions': questions_data, + 'total': N, + 'message': 'Answer all questions correctly to receive an API key' + }) + + +@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) + + 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': 30 + }) + else: + return JsonResponse({ + 'success': False, + 'message': 'Some answers were incorrect. Try again in 30 minutes.', + 'cooldown_seconds': 1800 + }, 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) From 0fc11b58a5c4a2edb6121a04069b06e9578f9259 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 16:38:51 +0000 Subject: [PATCH 03/56] CU-869b855n0: Use questionnaire endpoints --- medcat-demo-app/webapp/demo/urls.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index a884378e0..9af160cad 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -1,10 +1,21 @@ 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-questionnair/qet-client-id', get_client_identifier, + name="get-client-identifier"), + path('umls-license-questionnair/qet-questionnaire', get_questionnaire, + name="qet-questionnaire"), + path('umls-license-questionnair/submit-questionnaire', submit_questionnaire, + name="submit-questionnaire"), + path('umls-license-questionnair/check-api-key', check_api_key, + name="check-api-key"), ] From 31f47700a812e05a1648d9b0eb2fb6532823490d Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 16:40:21 +0000 Subject: [PATCH 04/56] CU-869b855n0: Add decorator for requiring valid API key --- medcat-demo-app/webapp/demo/decorators.py | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 medcat-demo-app/webapp/demo/decorators.py 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'} + }) +""" From 3799409a8190c283dbd2306281a2b22a9a78672c Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 16:41:57 +0000 Subject: [PATCH 05/56] CU-869b855n0: Add relevant admin bits to API keys and questions --- medcat-demo-app/webapp/demo/admin.py | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 7cce1297b..74f091887 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -14,3 +14,44 @@ class UploadedTextAdmin(admin.ModelAdmin): # Register your models here. admin.site.register(UploadedText, UploadedTextAdmin) +@admin.register(Question) +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' + + +@admin.register(UserAttempt) +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 + + +@admin.register(APIKey) +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 From 0cdc4e8ad534553015cfd4c174e3674aa868d0a5 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 16:57:48 +0000 Subject: [PATCH 06/56] CU-869b855n0: Add API-key requiring endpoint --- medcat-demo-app/webapp/demo/questionnaire.py | 39 ++++++++++++-------- medcat-demo-app/webapp/demo/urls.py | 2 + medcat-demo-app/webapp/demo/views.py | 11 ++++++ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py index aade82adc..2b0fbe308 100644 --- a/medcat-demo-app/webapp/demo/questionnaire.py +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -23,7 +23,7 @@ def get_client_identifier(request): 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) @@ -32,18 +32,18 @@ def get_questionnaire(request): 'cooldown_seconds': cooldown, 'message': f'Please wait {cooldown // 60} minutes before trying again' }, status=429) - + # Get random questions (adjust N as needed) N = 5 # Number of questions to ask questions = list(Question.objects.filter(is_active=True)) - + if len(questions) < N: return JsonResponse({ 'error': 'Not enough questions available' }, status=500) - + selected_questions = random.sample(questions, N) - + # Format questions for response questions_data = [] for q in selected_questions: @@ -57,7 +57,7 @@ def get_questionnaire(request): 'd': q.option_d, } }) - + return JsonResponse({ 'questions': questions_data, 'total': N, @@ -70,7 +70,7 @@ def get_questionnaire(request): 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) @@ -78,16 +78,16 @@ def submit_questionnaire(request): '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(): @@ -100,25 +100,32 @@ def submit_questionnaire(request): 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}/show-super-secret-stuff/?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': 30 + 'valid_for_minutes': 30, + 'secret_link': secret_url }) + else: return JsonResponse({ 'success': False, @@ -133,12 +140,12 @@ def submit_questionnaire(request): 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({ diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index 9af160cad..34b5cda26 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -18,4 +18,6 @@ name="submit-questionnaire"), path('umls-license-questionnair/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..a706f9f39 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' @@ -208,6 +209,16 @@ def validate_umls_api_key(request): 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()) + } + 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) From 6310810e24457f9eaf5b0a8b20cb5fa7cda958cc Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 19:17:46 +0000 Subject: [PATCH 07/56] Fix typo --- medcat-demo-app/webapp/demo/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index 34b5cda26..0c6b5952e 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -10,10 +10,10 @@ path('auth-callback-api', validate_umls_api_key, name='validate-umls-api-key'), path('download-model', download_model, name="download-model"), # questionnaire - path('umls-license-questionnair/qet-client-id', get_client_identifier, + path('umls-license-questionnair/get-client-id', get_client_identifier, name="get-client-identifier"), - path('umls-license-questionnair/qet-questionnaire', get_questionnaire, - name="qet-questionnaire"), + path('umls-license-questionnair/get-questionnaire', get_questionnaire, + name="get-questionnaire"), path('umls-license-questionnair/submit-questionnaire', submit_questionnaire, name="submit-questionnaire"), path('umls-license-questionnair/check-api-key', check_api_key, From 36c9bcfdf1c0618542d16ee49863844f694da4ce Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 19:19:11 +0000 Subject: [PATCH 08/56] Fix another few typos --- medcat-demo-app/webapp/demo/urls.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index 0c6b5952e..03791a059 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -10,13 +10,13 @@ path('auth-callback-api', validate_umls_api_key, name='validate-umls-api-key'), path('download-model', download_model, name="download-model"), # questionnaire - path('umls-license-questionnair/get-client-id', get_client_identifier, + path('umls-license-questionnaire/get-client-id', get_client_identifier, name="get-client-identifier"), - path('umls-license-questionnair/get-questionnaire', get_questionnaire, + path('umls-license-questionnaire/get-questionnaire', get_questionnaire, name="get-questionnaire"), - path('umls-license-questionnair/submit-questionnaire', submit_questionnaire, + path('umls-license-questionnaire/submit-questionnaire', submit_questionnaire, name="submit-questionnaire"), - path('umls-license-questionnair/check-api-key', check_api_key, + 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"), From 56eacaecf0dc1820736f6a4ed5178a0250033837 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 19:32:40 +0000 Subject: [PATCH 09/56] CU-869b855n0: Add UI for questionnaire --- medcat-demo-app/webapp/demo/questionnaire.py | 27 +- .../templates/questionnaire/cooldown.html | 147 +++++++++ .../demo/templates/questionnaire/error.html | 74 +++++ .../demo/templates/questionnaire/quiz.html | 310 ++++++++++++++++++ 4 files changed, 547 insertions(+), 11 deletions(-) create mode 100644 medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html create mode 100644 medcat-demo-app/webapp/demo/templates/questionnaire/error.html create mode 100644 medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py index 2b0fbe308..bc07261c4 100644 --- a/medcat-demo-app/webapp/demo/questionnaire.py +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -2,12 +2,16 @@ 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 from .models import Question, UserAttempt, APIKey +cooldown_minutes = 30 + + def get_client_identifier(request): """Get a unique identifier for the client (IP-based)""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') @@ -27,20 +31,21 @@ def get_questionnaire(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', + return render(request, 'questionnaire/cooldown.html', { 'cooldown_seconds': cooldown, - 'message': f'Please wait {cooldown // 60} minutes before trying again' - }, status=429) + 'cooldown_minutes': cooldown_minutes + }) + # Get random questions (adjust N as needed) N = 5 # Number of questions to ask questions = list(Question.objects.filter(is_active=True)) if len(questions) < N: - return JsonResponse({ - 'error': 'Not enough questions available' - }, status=500) + return render(request, 'questionnaire/error.html', { + 'error': 'Not enough questions available. Please contact the administrator.' + }) + selected_questions = random.sample(questions, N) @@ -58,13 +63,13 @@ def get_questionnaire(request): } }) - return JsonResponse({ - 'questions': questions_data, - 'total': N, - 'message': 'Answer all questions correctly to receive an API key' + return render(request, 'questionnaire/quiz.html', { + 'questions': selected_questions, + 'total': N }) + @csrf_exempt @require_http_methods(["POST"]) def submit_questionnaire(request): 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..5d1cd4a0c --- /dev/null +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html @@ -0,0 +1,147 @@ + + + + + + Cooldown Period + + + +
+
⏱️
+

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 +
+ + + + \ 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..c24495b8c --- /dev/null +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/error.html @@ -0,0 +1,74 @@ + + + + + + Error + + + +
+
⚠️
+

Oops!

+

We encountered an error while loading the questionnaire.

+ +
+ {{ error }} +
+
+ + \ 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..888cef19f --- /dev/null +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -0,0 +1,310 @@ + + + + + + Knowledge Questionnaire + + + +
+

🎯 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...

+
+ +
+
+ + + + \ No newline at end of file From b501a3a1b4fbe195b8287ca2d8a055c06f014451 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 19:34:58 +0000 Subject: [PATCH 10/56] CU-869b855n0: Change entry point for questionnaire --- medcat-demo-app/webapp/demo/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index 03791a059..c26832c7b 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -12,7 +12,7 @@ # questionnaire path('umls-license-questionnaire/get-client-id', get_client_identifier, name="get-client-identifier"), - path('umls-license-questionnaire/get-questionnaire', get_questionnaire, + path('umls-license-questionnaire/', get_questionnaire, name="get-questionnaire"), path('umls-license-questionnaire/submit-questionnaire', submit_questionnaire, name="submit-questionnaire"), From 674e61dfa7b08a85385c256307eda0d1dc7f0b60 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 21:39:33 +0000 Subject: [PATCH 11/56] CU-869b855n0: Unify registration of models and their admin stuff --- medcat-demo-app/webapp/demo/admin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 74f091887..5ebcecec2 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -7,14 +7,11 @@ def remove_text(modeladmin, request, queryset): UploadedText.objects.all().delete() + class UploadedTextAdmin(admin.ModelAdmin): model = UploadedText actions = [remove_text] -# Register your models here. -admin.site.register(UploadedText, UploadedTextAdmin) - -@admin.register(Question) class QuestionAdmin(admin.ModelAdmin): list_display = ('question_text_short', 'correct_answer', 'is_active') list_filter = ('is_active', 'correct_answer') @@ -25,7 +22,6 @@ def question_text_short(self, obj): question_text_short.short_description = 'Question' -@admin.register(UserAttempt) class UserAttemptAdmin(admin.ModelAdmin): list_display = ('identifier', 'attempted_at', 'passed') list_filter = ('passed', 'attempted_at') @@ -36,7 +32,6 @@ def has_add_permission(self, request): return False -@admin.register(APIKey) 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') @@ -55,3 +50,10 @@ def is_expired(self, obj): 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) From d7ffe546a5c466dc7d345bcb79c1b2d77d056b18 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 22:07:47 +0000 Subject: [PATCH 12/56] CU-869b855n0: Move migrations to startup instead of build time --- medcat-demo-app/docker-compose-test.yml | 7 ++++++- medcat-demo-app/docker-compose.yml | 5 +++++ medcat-demo-app/webapp/Dockerfile | 7 ------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/medcat-demo-app/docker-compose-test.yml b/medcat-demo-app/docker-compose-test.yml index fbb82a6d8..70bae26bd 100644 --- a/medcat-demo-app/docker-compose-test.yml +++ b/medcat-demo-app/docker-compose-test.yml @@ -8,7 +8,12 @@ services: REINSTALL_CORE_FROM_LOCAL: "true" command: > bash -c "/etc/init.d/cron start && - python /webapp/manage.py runserver 0.0.0.0:8000" + python manage.py makemigrations && + python manage.py makemigrations demo && + python manage.py migrate && + python manage.py migrate demo && + python manage.py collectstatic --noinput && + python /webapp/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..c6a571028 100644 --- a/medcat-demo-app/docker-compose.yml +++ b/medcat-demo-app/docker-compose.yml @@ -7,6 +7,11 @@ services: context: ./webapp command: > bash -c "/etc/init.d/cron start && + python manage.py makemigrations && + python manage.py makemigrations demo && + python manage.py migrate && + python manage.py migrate demo && + python manage.py collectstatic --noinput && python /webapp/manage.py runserver 0.0.0.0:8000" volumes: - ./webapp/data:/webapp/data diff --git a/medcat-demo-app/webapp/Dockerfile b/medcat-demo-app/webapp/Dockerfile index 4ce1c9ecf..1ef4b5bf0 100644 --- a/medcat-demo-app/webapp/Dockerfile +++ b/medcat-demo-app/webapp/Dockerfile @@ -61,10 +61,3 @@ WORKDIR /webapp # Create the db backup cron job (copied from your setup) 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 From 290883a931672b03091a2ff2d4bb29dc981924f9 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 22:16:12 +0000 Subject: [PATCH 13/56] CU-869b855n0: Collect static at Dockerfile time --- medcat-demo-app/webapp/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/medcat-demo-app/webapp/Dockerfile b/medcat-demo-app/webapp/Dockerfile index 1ef4b5bf0..d29f1bf56 100644 --- a/medcat-demo-app/webapp/Dockerfile +++ b/medcat-demo-app/webapp/Dockerfile @@ -61,3 +61,6 @@ WORKDIR /webapp # Create the db backup cron job (copied from your setup) 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 collect static (could be in entrypoint script) +RUN python manage.py collectstatic --noinput From b0eb578736f3c70cc2d0108eacaf2f4e63385112 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 22:21:35 +0000 Subject: [PATCH 14/56] CU-869b855n0: Update migrations --- .../migrations/0003_auto_20251120_2221.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py 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), + ), + ] From 7f4969599167da2f1f27cae36d8b69fbd8b7de4d Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 22:24:10 +0000 Subject: [PATCH 15/56] CU-869b855n0: Update / fix startup commnad --- medcat-demo-app/docker-compose-test.yml | 12 +++++------- medcat-demo-app/docker-compose.yml | 12 +++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/medcat-demo-app/docker-compose-test.yml b/medcat-demo-app/docker-compose-test.yml index 70bae26bd..5fefdbfb0 100644 --- a/medcat-demo-app/docker-compose-test.yml +++ b/medcat-demo-app/docker-compose-test.yml @@ -7,13 +7,11 @@ services: args: REINSTALL_CORE_FROM_LOCAL: "true" command: > - bash -c "/etc/init.d/cron start && - python manage.py makemigrations && - python manage.py makemigrations demo && - python manage.py migrate && - python manage.py migrate demo && - python manage.py collectstatic --noinput && - python /webapp/manage.py runserver 0.0.0.0:8000" && + bash -c " + python manage.py migrate --noinput && + python manage.py collectstatic --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 c6a571028..bca22a077 100644 --- a/medcat-demo-app/docker-compose.yml +++ b/medcat-demo-app/docker-compose.yml @@ -6,13 +6,11 @@ services: network: host context: ./webapp command: > - bash -c "/etc/init.d/cron start && - python manage.py makemigrations && - python manage.py makemigrations demo && - python manage.py migrate && - python manage.py migrate demo && - python manage.py collectstatic --noinput && - python /webapp/manage.py runserver 0.0.0.0:8000" + bash -c " + python manage.py migrate --noinput && + python manage.py collectstatic --noinput && + /etc/init.d/cron start && + python manage.py runserver 0.0.0.0:8000" volumes: - ./webapp/data:/webapp/data - ./webapp/db:/webapp/db From a0c73e33d356cd2a216c4f87d79fd617a766b841 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 22:25:02 +0000 Subject: [PATCH 16/56] CU-869b855n0: Remove collectstatic from CMD since it's done at build time --- medcat-demo-app/docker-compose-test.yml | 1 - medcat-demo-app/docker-compose.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/medcat-demo-app/docker-compose-test.yml b/medcat-demo-app/docker-compose-test.yml index 5fefdbfb0..6b0882cd0 100644 --- a/medcat-demo-app/docker-compose-test.yml +++ b/medcat-demo-app/docker-compose-test.yml @@ -9,7 +9,6 @@ services: command: > bash -c " python manage.py migrate --noinput && - python manage.py collectstatic --noinput && /etc/init.d/cron start && python manage.py runserver 0.0.0.0:8000" volumes: diff --git a/medcat-demo-app/docker-compose.yml b/medcat-demo-app/docker-compose.yml index bca22a077..d75168e24 100644 --- a/medcat-demo-app/docker-compose.yml +++ b/medcat-demo-app/docker-compose.yml @@ -8,7 +8,6 @@ services: command: > bash -c " python manage.py migrate --noinput && - python manage.py collectstatic --noinput && /etc/init.d/cron start && python manage.py runserver 0.0.0.0:8000" volumes: From d5b59b293511e233c884408f48e3494f48be19b7 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 22:58:54 +0000 Subject: [PATCH 17/56] CU-869b855n0: Fix endpoint from questionnaire to submit --- medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html index 888cef19f..e6ef915aa 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -257,7 +257,7 @@

🎯 Knowledge Challenge

resultDiv.style.display = 'none'; try { - const response = await fetch('/questionnaire/submit/', { + const response = await fetch('/questionnaire/submit-questionnaire/', { method: 'POST', headers: { 'Content-Type': 'application/json', From f5199f87550020da3b324321b816ab652598b6e4 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:07:54 +0000 Subject: [PATCH 18/56] CU-869b855n0: Fix another endpoint typo --- medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html index e6ef915aa..12970ae37 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -257,7 +257,7 @@

🎯 Knowledge Challenge

resultDiv.style.display = 'none'; try { - const response = await fetch('/questionnaire/submit-questionnaire/', { + const response = await fetch('/umls-license-questionnaire/submit-questionnaire/', { method: 'POST', headers: { 'Content-Type': 'application/json', From fdf008234db987ec015cec1d69c0486151194adb Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:13:06 +0000 Subject: [PATCH 19/56] CU-869b855n0: Fix yet another endpoint typo --- medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html index 12970ae37..60aed1d14 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -257,7 +257,7 @@

🎯 Knowledge Challenge

resultDiv.style.display = 'none'; try { - const response = await fetch('/umls-license-questionnaire/submit-questionnaire/', { + const response = await fetch('/umls-license-questionnaire/submit-questionnaire', { method: 'POST', headers: { 'Content-Type': 'application/json', From 58e2ec053fd2fa69258b417ba8c20c227033ce66 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:19:15 +0000 Subject: [PATCH 20/56] CU-869b855n0: Fix callback URL --- medcat-demo-app/webapp/demo/questionnaire.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py index bc07261c4..307fa3036 100644 --- a/medcat-demo-app/webapp/demo/questionnaire.py +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -120,7 +120,7 @@ def submit_questionnaire(request): # 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}/show-super-secret-stuff/?api_key={api_key.key}" + secret_url = f"{scheme}://{host}/callback-after-questionnaire/?api_key={api_key.key}" return JsonResponse({ 'success': True, From 2a5048243ace8b4fc02ae185f41ffe03717f6eee Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:20:03 +0000 Subject: [PATCH 21/56] CU-869b855n0: Update template for better accuracy --- medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html index 60aed1d14..20072007d 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -282,7 +282,7 @@

🎯 Knowledge Challenge

Valid for: ${data.valid_for_minutes} minutes

Expires at: ${new Date(data.expires_at).toLocaleString()}

- πŸ”“ Access Secret Content + πŸ”“ Access Models `; } else { resultDiv.className = 'result-error'; From 2f5206fdb889c74cfa3901b31478a6dfdd11c855 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:29:51 +0000 Subject: [PATCH 22/56] CU-869b855n0: Fix URL in setup for callback endpoint --- medcat-demo-app/webapp/demo/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index c26832c7b..7db71d3e2 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -18,6 +18,6 @@ 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, + path('callback-after-questionnaire/', model_after_api_key, name="model_after_api_key"), ] From 49bc6c5740e2c3791cf03cc235bee4fe643b89ee Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:37:09 +0000 Subject: [PATCH 23/56] CU-869b855n0: Add medvat version to more contexts --- medcat-demo-app/webapp/demo/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/medcat-demo-app/webapp/demo/views.py b/medcat-demo-app/webapp/demo/views.py index a706f9f39..fa3306ae5 100644 --- a/medcat-demo-app/webapp/demo/views.py +++ b/medcat-demo-app/webapp/demo/views.py @@ -202,6 +202,7 @@ 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() @@ -216,6 +217,7 @@ def model_after_api_key(request): '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) From 6a095f4c81ee430ca83fc5e7c7eb7e65577cc374 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:39:14 +0000 Subject: [PATCH 24/56] CU-869b855n0: Add medcat version to more contexts (in questionnaire) --- medcat-demo-app/webapp/demo/questionnaire.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py index 307fa3036..34e2a0884 100644 --- a/medcat-demo-app/webapp/demo/questionnaire.py +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -6,6 +6,8 @@ import json import random +from medcat import __version__ as medcat_version + from .models import Question, UserAttempt, APIKey @@ -33,7 +35,8 @@ def get_questionnaire(request): cooldown = UserAttempt.get_cooldown_remaining(identifier) return render(request, 'questionnaire/cooldown.html', { 'cooldown_seconds': cooldown, - 'cooldown_minutes': cooldown_minutes + 'cooldown_minutes': cooldown_minutes, + 'medcat_version': medcat_version, }) @@ -43,7 +46,8 @@ def get_questionnaire(request): if len(questions) < N: return render(request, 'questionnaire/error.html', { - 'error': 'Not enough questions available. Please contact the administrator.' + 'error': 'Not enough questions available. Please contact the administrator.', + 'medcat_version': medcat_version, }) @@ -65,7 +69,8 @@ def get_questionnaire(request): return render(request, 'questionnaire/quiz.html', { 'questions': selected_questions, - 'total': N + 'total': N, + 'medcat_version': medcat_version, }) From 4fe7f580c4c8d0a18ea153de212d7bfb36107ef5 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:40:48 +0000 Subject: [PATCH 25/56] CU-869b855n0: Make number of questions dynamic --- medcat-demo-app/webapp/demo/questionnaire.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py index 34e2a0884..a9f25d4ea 100644 --- a/medcat-demo-app/webapp/demo/questionnaire.py +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -5,6 +5,7 @@ from django.shortcuts import render import json import random +import os from medcat import __version__ as medcat_version @@ -24,6 +25,10 @@ def get_client_identifier(request): 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): @@ -41,7 +46,7 @@ def get_questionnaire(request): # Get random questions (adjust N as needed) - N = 5 # Number of questions to ask + N = get_number_of_questions() questions = list(Question.objects.filter(is_active=True)) if len(questions) < N: From 2b58745fe47e7f388242709a8cc190d386fc93eb Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:41:51 +0000 Subject: [PATCH 26/56] CU-869b855n0: Add base template to quiz --- .../webapp/demo/templates/questionnaire/quiz.html | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html index 20072007d..e813982f2 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -1,9 +1,6 @@ - - - - - - Knowledge Questionnaire +{% 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 %} - - \ No newline at end of file +{% endblock %} \ No newline at end of file From dc0c516f6d97bfad603d91e65b58efbd2dfe2354 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:46:22 +0000 Subject: [PATCH 29/56] CU-869b855n0: Add base template to error template --- .../demo/templates/questionnaire/error.html | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/error.html b/medcat-demo-app/webapp/demo/templates/questionnaire/error.html index c24495b8c..6abeaaebe 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/error.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/error.html @@ -1,27 +1,28 @@ - - - - - - Error +{% extends "base.html" %} + +{% block style %} - - -
+{% endblock %} + +{% block body %} +
+
⚠️

Oops!

We encountered an error while loading the questionnaire.

- +
{{ error }}
- - \ No newline at end of file +
+{% endblock %} \ No newline at end of file From 55b4c1173cbe1560d2895dfb11fa4570795e0dd0 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 20 Nov 2025 23:48:41 +0000 Subject: [PATCH 30/56] CU-869b855n0: Some whitespace changes --- .../demo/templates/questionnaire/quiz.html | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html index b7189ccc4..000fc5c61 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html @@ -7,7 +7,7 @@ padding: 0; box-sizing: border-box; } - + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); @@ -17,7 +17,7 @@ justify-content: center; align-items: center; } - + .container { max-width: 700px; width: 100%; @@ -26,19 +26,19 @@ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); padding: 40px; } - + h1 { color: #333; margin-bottom: 10px; font-size: 28px; } - + .subtitle { color: #666; margin-bottom: 30px; font-size: 14px; } - + .question { margin-bottom: 30px; padding: 25px; @@ -46,7 +46,7 @@ border-radius: 12px; border-left: 4px solid #667eea; } - + .question-number { font-weight: 600; color: #667eea; @@ -55,20 +55,20 @@ text-transform: uppercase; letter-spacing: 0.5px; } - + .question-text { font-size: 18px; color: #333; margin-bottom: 20px; line-height: 1.6; } - + .options { display: flex; flex-direction: column; gap: 12px; } - + .option { display: flex; align-items: center; @@ -79,12 +79,12 @@ cursor: pointer; transition: all 0.2s ease; } - + .option:hover { border-color: #667eea; background: #f0f4ff; } - + .option input[type="radio"] { margin-right: 12px; cursor: pointer; @@ -92,14 +92,14 @@ height: 18px; accent-color: #667eea; } - + .option label { cursor: pointer; flex: 1; font-size: 16px; color: #333; } - + .submit-btn { width: 100%; padding: 16px; @@ -113,47 +113,47 @@ transition: transform 0.2s ease, box-shadow 0.2s ease; margin-top: 10px; } - + .submit-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4); } - + .submit-btn:active { transform: translateY(0); } - + .submit-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } - + #result { margin-top: 20px; padding: 20px; border-radius: 8px; display: none; } - + .result-success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; } - + .result-error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; } - + .result-title { font-weight: 600; font-size: 18px; margin-bottom: 10px; } - + .api-key-box { background: #fff; padding: 15px; @@ -164,7 +164,7 @@ word-break: break-all; font-size: 13px; } - + .secret-link { display: inline-block; margin-top: 15px; @@ -176,11 +176,11 @@ font-weight: 600; transition: background 0.2s ease; } - + .secret-link:hover { background: #218838; } - + .loading { display: none; text-align: center; @@ -193,7 +193,7 @@

🎯 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 %} @@ -220,39 +220,39 @@

🎯 Knowledge Challenge

{% endfor %} - + - +

⏳ Checking your answers...

- +
- - \ No newline at end of file +{% endblock %} \ No newline at end of file From f9723fcb9266b90d8f1dd999fe4f27859f93008d Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 21 Nov 2025 09:09:53 +0000 Subject: [PATCH 32/56] CU-869b855n0: Fix URL for retry on cooldown --- .../webapp/demo/templates/questionnaire/cooldown.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html index a3e563a6d..a3e84ef4b 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html @@ -112,7 +112,7 @@

Cooldown Period

{{ cooldown_minutes }}:00
- Try Again + Try Again {% endblock %} From 20b0ac585c3b8800217af413ca0e84feda23c821 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 21 Nov 2025 12:52:10 +0000 Subject: [PATCH 33/56] CU-869b855n0: Add link to questionnaire (and UMLS license) to API key based view --- .../demo/templates/umls_api_key_entry.html | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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..42c5f68b0 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 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. +
+
+
+
{% endblock %} From 64d6e78706e27c4955299cd78ba47bd24438f5bc Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 21 Nov 2025 13:00:35 +0000 Subject: [PATCH 34/56] CU-869b855n0: Update template to include details on expiry and attempts --- .../webapp/demo/templates/umls_api_key_entry.html | 6 ++++++ 1 file changed, 6 insertions(+) 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 42c5f68b0..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 @@ -62,6 +62,12 @@
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. +

From 71839b259a85dc9ad8eb2643bdf24266528b6769 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 21 Nov 2025 15:26:00 +0000 Subject: [PATCH 35/56] CU-869b855n0: Fix missing medcat version context --- medcat-demo-app/webapp/demo/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/medcat-demo-app/webapp/demo/views.py b/medcat-demo-app/webapp/demo/views.py index fa3306ae5..03b65df86 100644 --- a/medcat-demo-app/webapp/demo/views.py +++ b/medcat-demo-app/webapp/demo/views.py @@ -207,6 +207,7 @@ def validate_umls_api_key(request): else: form = UMLSApiKeyForm() + context['medcat_version'] = medcat_version return render(request, 'umls_api_key_entry.html', {'form': form}) From 568ebb527c3a7e0e82d9b822abe7c89f863ad5ba Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 21 Nov 2025 15:40:10 +0000 Subject: [PATCH 36/56] CU-869b855n0: Centralise cooldown time --- medcat-demo-app/webapp/demo/models.py | 11 +++++++---- medcat-demo-app/webapp/demo/questionnaire.py | 12 +++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/medcat-demo-app/webapp/demo/models.py b/medcat-demo-app/webapp/demo/models.py index d1b5cef45..21942e8fe 100644 --- a/medcat-demo-app/webapp/demo/models.py +++ b/medcat-demo-app/webapp/demo/models.py @@ -10,6 +10,9 @@ MODEL_FS = FileSystemStorage(location="/medcat_data") +cooldown_minutes = 30 + + # Create your models here. class UploadedText(models.Model): text = models.TextField(default="", blank=True) @@ -65,7 +68,7 @@ class Meta: @classmethod def can_attempt(cls, identifier): """Check if user can attempt the questionnaire""" - thirty_mins_ago = timezone.now() - timedelta(minutes=30) + thirty_mins_ago = timezone.now() - timedelta(minutes=cooldown_minutes) recent_failed = cls.objects.filter( identifier=identifier, attempted_at__gte=thirty_mins_ago, @@ -76,7 +79,7 @@ def can_attempt(cls, identifier): @classmethod def get_cooldown_remaining(cls, identifier): """Get remaining cooldown time in seconds""" - thirty_mins_ago = timezone.now() - timedelta(minutes=30) + thirty_mins_ago = timezone.now() - timedelta(minutes=cooldown_minutes) recent_failed = cls.objects.filter( identifier=identifier, attempted_at__gte=thirty_mins_ago, @@ -85,7 +88,7 @@ def get_cooldown_remaining(cls, identifier): if recent_failed: time_passed = timezone.now() - recent_failed.attempted_at - remaining = timedelta(minutes=30) - time_passed + remaining = timedelta(minutes=cooldown_minutes) - time_passed return max(0, int(remaining.total_seconds())) return 0 @@ -102,7 +105,7 @@ 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=30) + self.expires_at = timezone.now() + timedelta(minutes=cooldown_minutes) super().save(*args, **kwargs) @classmethod diff --git a/medcat-demo-app/webapp/demo/questionnaire.py b/medcat-demo-app/webapp/demo/questionnaire.py index a9f25d4ea..408c8cfe8 100644 --- a/medcat-demo-app/webapp/demo/questionnaire.py +++ b/medcat-demo-app/webapp/demo/questionnaire.py @@ -9,10 +9,7 @@ from medcat import __version__ as medcat_version -from .models import Question, UserAttempt, APIKey - - -cooldown_minutes = 30 +from .models import Question, UserAttempt, APIKey, cooldown_minutes def get_client_identifier(request): @@ -137,15 +134,16 @@ def submit_questionnaire(request): 'message': 'All answers correct! API key generated.', 'api_key': api_key.key, 'expires_at': api_key.expires_at.isoformat(), - 'valid_for_minutes': 30, + 'valid_for_minutes': cooldown_minutes, 'secret_link': secret_url }) else: return JsonResponse({ 'success': False, - 'message': 'Some answers were incorrect. Try again in 30 minutes.', - 'cooldown_seconds': 1800 + 'message': 'Some answers were incorrect. ' + f'Try again in {cooldown_minutes} minutes.', + 'cooldown_seconds': cooldown_minutes * 60, }, status=403) From 25dab531cad0392b7abea8cf70718633d87087b4 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 21 Nov 2025 15:57:29 +0000 Subject: [PATCH 37/56] CU-869b855n0: Fix cooldown (hopefully) --- .../webapp/demo/templates/questionnaire/cooldown.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html index a3e84ef4b..4477e2153 100644 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html +++ b/medcat-demo-app/webapp/demo/templates/questionnaire/cooldown.html @@ -119,7 +119,7 @@

Cooldown Period

{% 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 deleted file mode 100644 index 6abeaaebe..000000000 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/error.html +++ /dev/null @@ -1,77 +0,0 @@ -{% 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 deleted file mode 100644 index 982d93cde..000000000 --- a/medcat-demo-app/webapp/demo/templates/questionnaire/quiz.html +++ /dev/null @@ -1,311 +0,0 @@ -{% extends "base.html" %} - -{% block style %} - -{% endblock %} - -{% block body %} -
-
-

🎯 Knowledge Challenge

-

Answer all {{ total }} questions correctly to receive acces to models. 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/urls.py b/medcat-demo-app/webapp/demo/urls.py index 7db71d3e2..8f79569f7 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -1,8 +1,6 @@ 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'), @@ -10,14 +8,6 @@ path('auth-callback-api', validate_umls_api_key, name='validate-umls-api-key'), 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"), ] From b90954023e38fee8981ec30d00109fe4a530d302 Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 10:42:33 +0000 Subject: [PATCH 43/56] CU-869b855n0: Fix issue with show lib version --- medcat-demo-app/webapp/demo/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/medcat-demo-app/webapp/demo/views.py b/medcat-demo-app/webapp/demo/views.py index 03b65df86..5b1f32066 100644 --- a/medcat-demo-app/webapp/demo/views.py +++ b/medcat-demo-app/webapp/demo/views.py @@ -207,8 +207,8 @@ def validate_umls_api_key(request): else: form = UMLSApiKeyForm() - context['medcat_version'] = medcat_version - 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 From f470e44291c33d7ef2093223cfc032bb6b7b099c Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 11:32:25 +0000 Subject: [PATCH 44/56] CU-869b855n0: Allow (by default) 14 days for API key cooldown --- medcat-demo-app/webapp/demo/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/medcat-demo-app/webapp/demo/models.py b/medcat-demo-app/webapp/demo/models.py index fa4b8ab74..15c6f9cf4 100644 --- a/medcat-demo-app/webapp/demo/models.py +++ b/medcat-demo-app/webapp/demo/models.py @@ -10,7 +10,7 @@ MODEL_FS = FileSystemStorage(location="/medcat_data") -cooldown_minutes = 30 +cooldown_days = 14 # Create your models here. @@ -51,7 +51,7 @@ 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) + self.expires_at = timezone.now() + timedelta(days=cooldown_days) super().save(*args, **kwargs) @classmethod From 02e49c264e5ec2ee97f93b4a090468617b31f0d8 Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 11:33:53 +0000 Subject: [PATCH 45/56] CU-869b855n0: Add permission to manually add API keys --- medcat-demo-app/webapp/demo/admin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 6f650486b..7487fbc4b 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -29,9 +29,6 @@ def is_expired(self, obj): 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) From 0b722bfc49310115464003da9f6a3ce681a4bf68 Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 12:25:20 +0000 Subject: [PATCH 46/56] CU-869b855n0: Allow changing expiry date of API key --- medcat-demo-app/webapp/demo/admin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 7487fbc4b..ed5212ffc 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -17,7 +17,12 @@ 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 get_readonly_fields(self, request, obj=None): + if obj: # Editing an existing object + return ('key', 'created_at', 'expires_at') + else: # Creating a new object + return ('key', 'created_at') def key_short(self, obj): return f"{obj.key[:10]}..." From 6393910a1f6a5a5402fbe05a8b0d91317dff83a4 Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 12:26:31 +0000 Subject: [PATCH 47/56] CU-869b855n0: Update manual API callback URL --- medcat-demo-app/webapp/demo/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index 8f79569f7..fbb8c11a4 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -8,6 +8,6 @@ path('auth-callback-api', validate_umls_api_key, name='validate-umls-api-key'), path('download-model', download_model, name="download-model"), # questionnaire - path('callback-after-questionnaire/', model_after_api_key, + path('manual-api-callback/', model_after_api_key, name="model_after_api_key"), ] From fedf1ada754298e0ee1bfee5e1cea6596d6a1f60 Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 12:47:05 +0000 Subject: [PATCH 48/56] CU-869b855n0: Add API key link --- medcat-demo-app/webapp/demo/admin.py | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index ed5212ffc..15bf0fe71 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -34,6 +34,36 @@ def is_expired(self, obj): is_expired.boolean = True is_expired.short_description = 'Expired' + def api_key_link(self, obj): + if obj.key and obj.is_active: + callback_url = f"/manual-api-callback/?api_key={self.key}" + unique_id = self.identifier + + return format_html( + '
' + ' ' + '' + '' + '
' + '', + callback_url, # 1st {} - input value + callback_url, # 2nd {} - text to copy + unique_id, # 3rd {} - ID for JavaScript function + unique_id # 4th {} - ID for status span + ) + return "-" + api_key_link.short_description = 'API Key URL' + # Register your models here. admin.site.register(UploadedText, UploadedTextAdmin) From d309f3805a684de41752532a43be2b3e3bc273fc Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:01:41 +0000 Subject: [PATCH 49/56] CU-869b855n0: Update admin view with correct URL --- medcat-demo-app/webapp/demo/admin.py | 26 ++++++++++++----------- medcat-demo-app/webapp/webapp/settings.py | 3 +++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 15bf0fe71..174f32d59 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) @@ -20,9 +23,9 @@ class APIKeyAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): if obj: # Editing an existing object - return ('key', 'created_at', 'expires_at') + return ('key', 'created_at', 'api_key_link', 'expires_at') else: # Creating a new object - return ('key', 'created_at') + return ('key', 'created_at', 'api_key_link') def key_short(self, obj): return f"{obj.key[:10]}..." @@ -34,12 +37,14 @@ def is_expired(self, obj): is_expired.boolean = True is_expired.short_description = 'Expired' - def api_key_link(self, obj): - if obj.key and obj.is_active: - callback_url = f"/manual-api-callback/?api_key={self.key}" - unique_id = self.identifier + 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 - return format_html( + formatted = format_html( '
' ' ' @@ -55,12 +60,9 @@ def api_key_link(self, obj): ' document.getElementById("copy-status-" + id).textContent = "";' ' }}, 2000);' '}}' - '', - callback_url, # 1st {} - input value - callback_url, # 2nd {} - text to copy - unique_id, # 3rd {} - ID for JavaScript function - unique_id # 4th {} - ID for status span + '', callback_url, callback_url, unique_id, unique_id, ) + return formatted return "-" api_key_link.short_description = 'API Key URL' 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/ From af29da5270337ed744e37f3b47fa4fe58d78987a Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:08:55 +0000 Subject: [PATCH 50/56] CU-869b855n0: Some formatting changes to dockerfile --- medcat-demo-app/webapp/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/medcat-demo-app/webapp/Dockerfile b/medcat-demo-app/webapp/Dockerfile index d29f1bf56..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 \ From 59ffb950df72c6c472f891fdddca7427ffeb4ef8 Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:10:41 +0000 Subject: [PATCH 51/56] CU-869b855n0: Fix copy link --- medcat-demo-app/webapp/demo/admin.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/medcat-demo-app/webapp/demo/admin.py b/medcat-demo-app/webapp/demo/admin.py index 174f32d59..f936542dc 100644 --- a/medcat-demo-app/webapp/demo/admin.py +++ b/medcat-demo-app/webapp/demo/admin.py @@ -46,21 +46,19 @@ def api_key_link(self, obj: APIKey): formatted = format_html( '
' - ' ' - '' + '' '' - '
' - '', callback_url, callback_url, unique_id, unique_id, + '
', + callback_url, unique_id, callback_url, unique_id, unique_id, unique_id ) return formatted return "-" From 9e6e0d0f36dd48614eb600c17ae84a952db723cd Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:14:18 +0000 Subject: [PATCH 52/56] CU-869b855n0: Remove mention of questionnaire --- medcat-demo-app/webapp/demo/decorators.py | 2 +- medcat-demo-app/webapp/demo/urls.py | 2 +- medcat-demo-app/webapp/demo/views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/medcat-demo-app/webapp/demo/decorators.py b/medcat-demo-app/webapp/demo/decorators.py index be71476f0..5de46c3d7 100644 --- a/medcat-demo-app/webapp/demo/decorators.py +++ b/medcat-demo-app/webapp/demo/decorators.py @@ -31,7 +31,7 @@ def wrapper(request, *args, **kwargs): 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' + 'message': 'Please obtain a valid API key' }, status=401) # API key is valid, proceed with the view diff --git a/medcat-demo-app/webapp/demo/urls.py b/medcat-demo-app/webapp/demo/urls.py index fbb8c11a4..cf374b2df 100644 --- a/medcat-demo-app/webapp/demo/urls.py +++ b/medcat-demo-app/webapp/demo/urls.py @@ -7,7 +7,7 @@ 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"), - # questionnaire + # 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 5b1f32066..5f87cb711 100644 --- a/medcat-demo-app/webapp/demo/views.py +++ b/medcat-demo-app/webapp/demo/views.py @@ -215,7 +215,7 @@ def validate_umls_api_key(request): def model_after_api_key(request): context = { 'is_valid': True, - 'message': f'Questionnaire based API key is being used', + 'message': f'Manually obtained API key is being used', 'downloader_form': DownloaderForm(MedcatModel.objects.all()) } context['medcat_version'] = medcat_version From acb81e8cb251fef8e4784c560899c6ad9aec431f Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:16:41 +0000 Subject: [PATCH 53/56] CU-869b855n0: Add default value for expiry of API keys --- medcat-demo-app/webapp/demo/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/models.py b/medcat-demo-app/webapp/demo/models.py index 15c6f9cf4..2743c4fff 100644 --- a/medcat-demo-app/webapp/demo/models.py +++ b/medcat-demo-app/webapp/demo/models.py @@ -44,7 +44,8 @@ class APIKey(models.Model): 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() + expires_at = models.DateTimeField( + default=lambda: timezone.now() + timedelta(days=cooldown_days)) is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): From ebd42866eac135efbc5f07b5c763a739dee88dbd Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:21:41 +0000 Subject: [PATCH 54/56] CU-869b855n0: Update expiry default with picklable method --- medcat-demo-app/webapp/demo/models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/medcat-demo-app/webapp/demo/models.py b/medcat-demo-app/webapp/demo/models.py index 2743c4fff..ee0793291 100644 --- a/medcat-demo-app/webapp/demo/models.py +++ b/medcat-demo-app/webapp/demo/models.py @@ -39,13 +39,17 @@ class MedcatModel(models.Model): 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=lambda: timezone.now() + timedelta(days=cooldown_days)) + default=_default_expiry) is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): From fd20cab5bef610935cd50e152a4446706b0a81bb Mon Sep 17 00:00:00 2001 From: mart-r Date: Mon, 24 Nov 2025 16:22:18 +0000 Subject: [PATCH 55/56] CU-869b855n0: Add migration for default expiry --- .../0004_alter_apikey_expires_at.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 medcat-demo-app/webapp/demo/migrations/0004_alter_apikey_expires_at.py 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), + ), + ] From 7649832d016eaaf421f13e92282f042b4638e6ee Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 28 Nov 2025 15:59:57 +0000 Subject: [PATCH 56/56] CU-869b855n0: Minor reword --- .../webapp/demo/templates/umls_api_key_entry.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 1ea774b7d..e1dd1a5df 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 @@ -57,9 +57,8 @@
- 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 + If you are unable to obtain an API key due to your application for a UMLS license being unanswered, + 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.