Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
955105e
CU-869b855n0: Add models for questionnaire
mart-r Nov 20, 2025
171eeb6
CU-869b855n0: Add endpoints for questionnaire
mart-r Nov 20, 2025
0fc11b5
CU-869b855n0: Use questionnaire endpoints
mart-r Nov 20, 2025
31f4770
CU-869b855n0: Add decorator for requiring valid API key
mart-r Nov 20, 2025
3799409
CU-869b855n0: Add relevant admin bits to API keys and questions
mart-r Nov 20, 2025
0cdc4e8
CU-869b855n0: Add API-key requiring endpoint
mart-r Nov 20, 2025
6310810
Fix typo
mart-r Nov 20, 2025
36c9bcf
Fix another few typos
mart-r Nov 20, 2025
56eacae
CU-869b855n0: Add UI for questionnaire
mart-r Nov 20, 2025
b501a3a
CU-869b855n0: Change entry point for questionnaire
mart-r Nov 20, 2025
674e61d
CU-869b855n0: Unify registration of models and their admin stuff
mart-r Nov 20, 2025
d7ffe54
CU-869b855n0: Move migrations to startup instead of build time
mart-r Nov 20, 2025
290883a
CU-869b855n0: Collect static at Dockerfile time
mart-r Nov 20, 2025
b0eb578
CU-869b855n0: Update migrations
mart-r Nov 20, 2025
7f49695
CU-869b855n0: Update / fix startup commnad
mart-r Nov 20, 2025
a0c73e3
CU-869b855n0: Remove collectstatic from CMD since it's done at build …
mart-r Nov 20, 2025
d5b59b2
CU-869b855n0: Fix endpoint from questionnaire to submit
mart-r Nov 20, 2025
f5199f8
CU-869b855n0: Fix another endpoint typo
mart-r Nov 20, 2025
fdf0082
CU-869b855n0: Fix yet another endpoint typo
mart-r Nov 20, 2025
58e2ec0
CU-869b855n0: Fix callback URL
mart-r Nov 20, 2025
2a50482
CU-869b855n0: Update template for better accuracy
mart-r Nov 20, 2025
2f5206f
CU-869b855n0: Fix URL in setup for callback endpoint
mart-r Nov 20, 2025
49bc6c5
CU-869b855n0: Add medvat version to more contexts
mart-r Nov 20, 2025
6a095f4
CU-869b855n0: Add medcat version to more contexts (in questionnaire)
mart-r Nov 20, 2025
4fe7f58
CU-869b855n0: Make number of questions dynamic
mart-r Nov 20, 2025
2b58745
CU-869b855n0: Add base template to quiz
mart-r Nov 20, 2025
c685911
CU-869b855n0: Fix URL in template again
mart-r Nov 20, 2025
363f50e
CU-869b855n0: Add base template to cooldown template
mart-r Nov 20, 2025
dc0c516
CU-869b855n0: Add base template to error template
mart-r Nov 20, 2025
55b4c11
CU-869b855n0: Some whitespace changes
mart-r Nov 20, 2025
16ebb2b
CU-869b855n0: Fix quiz template
mart-r Nov 21, 2025
f9723fc
CU-869b855n0: Fix URL for retry on cooldown
mart-r Nov 21, 2025
20b0ac5
CU-869b855n0: Add link to questionnaire (and UMLS license) to API key…
mart-r Nov 21, 2025
64d6e78
CU-869b855n0: Update template to include details on expiry and attempts
mart-r Nov 21, 2025
71839b2
CU-869b855n0: Fix missing medcat version context
mart-r Nov 21, 2025
568ebb5
CU-869b855n0: Centralise cooldown time
mart-r Nov 21, 2025
25dab53
CU-869b855n0: Fix cooldown (hopefully)
mart-r Nov 21, 2025
726c351
CU-869b855n0: Avoid treating a migration with error in its name as an…
mart-r Nov 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/medcat-demo-app_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ jobs:
- name: Check container logs for errors
run: |
docker-compose logs medcatweb
docker-compose logs medcatweb | grep -i 'error' && exit 1 || true
# NOTE: ignore line "Applying auth.0007_alter_validators_add_error_messages" - not an error
docker-compose logs medcatweb | grep -v "Applying auth.0007_alter_validators_add_error_messages" | grep -i 'error' && exit 1 || true

- name: Tear down
run: docker-compose -f docker-compose-test.yml down
6 changes: 4 additions & 2 deletions medcat-demo-app/docker-compose-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ services:
args:
REINSTALL_CORE_FROM_LOCAL: "true"
command: >
bash -c "/etc/init.d/cron start &&
python /webapp/manage.py runserver 0.0.0.0:8000"
bash -c "
python manage.py migrate --noinput &&
/etc/init.d/cron start &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- ../medcat-v2/tests/resources:/webapp/models
ports:
Expand Down
6 changes: 4 additions & 2 deletions medcat-demo-app/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ services:
network: host
context: ./webapp
command: >
bash -c "/etc/init.d/cron start &&
python /webapp/manage.py runserver 0.0.0.0:8000"
bash -c "
python manage.py migrate --noinput &&
/etc/init.d/cron start &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- ./webapp/data:/webapp/data
- ./webapp/db:/webapp/db
Expand Down
8 changes: 2 additions & 6 deletions medcat-demo-app/webapp/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 44 additions & 1 deletion medcat-demo-app/webapp/demo/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
53 changes: 53 additions & 0 deletions medcat-demo-app/webapp/demo/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from functools import wraps
from django.http import JsonResponse
from .models import APIKey


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

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

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

if not APIKey.is_valid(api_key):
return JsonResponse({
'error': 'Invalid or expired API key',
'message': 'Please 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'}
})
"""
59 changes: 59 additions & 0 deletions medcat-demo-app/webapp/demo/migrations/0003_auto_20251120_2221.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,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),
),
]
96 changes: 96 additions & 0 deletions medcat-demo-app/webapp/demo/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import secrets

from datetime import timedelta

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


MODEL_FS = FileSystemStorage(location="/medcat_data")


cooldown_minutes = 30


# Create your models here.
class UploadedText(models.Model):
text = models.TextField(default="", blank=True)
Expand All @@ -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})"
Loading