feat: Remove slug field from ResearchStudy model and update related references

This commit is contained in:
Ross
2026-05-12 21:51:44 +01:00
parent c94d2fb1a7
commit 89e89cef73
10 changed files with 68 additions and 35 deletions
+21
View File
@@ -3,6 +3,27 @@ from django.template import Node, TemplateSyntaxError, Variable
register = template.Library() register = template.Library()
@register.simple_tag(takes_context=True)
def update_query_param(context, key, value):
"""Return a querystring with a dynamic key updated.
Django's built-in querystring tag does not allow dynamic keyword names,
so this helper keeps compatibility for django-tables2 prefixed fields.
"""
request = context.get("request")
if request is None:
return ""
params = request.GET.copy()
if value in (None, ""):
params.pop(key, None)
else:
params[key] = value
encoded = params.urlencode()
return f"?{encoded}" if encoded else ""
class HelpNode(Node): class HelpNode(Node):
def __init__(self, nodelist, title, icon, extra_class): def __init__(self, nodelist, title, icon, extra_class):
self.nodelist = nodelist self.nodelist = nodelist
+4 -4
View File
@@ -5,13 +5,13 @@ from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant
@admin.register(ResearchStudy) @admin.register(ResearchStudy)
class ResearchStudyAdmin(admin.ModelAdmin): class ResearchStudyAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'active', 'generation_mode', 'randomisation_mode', 'created_at'] list_display = ['id', 'name', 'active', 'generation_mode', 'randomisation_mode', 'created_at']
list_filter = ['active', 'generation_mode', 'randomisation_mode', 'created_at'] list_filter = ['active', 'generation_mode', 'randomisation_mode', 'created_at']
search_fields = ['name', 'slug', 'description'] search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
fieldsets = ( fieldsets = (
('Study Information', { ('Study Information', {
'fields': ('name', 'slug', 'description', 'active') 'fields': ('name', 'description', 'active')
}), }),
('Participant Creation', { ('Participant Creation', {
'fields': ('generation_mode', 'collect_demographics'), 'fields': ('generation_mode', 'collect_demographics'),
@@ -64,7 +64,7 @@ class ResearchStudyArmAdmin(admin.ModelAdmin):
class ResearchParticipantAdmin(admin.ModelAdmin): class ResearchParticipantAdmin(admin.ModelAdmin):
list_display = ['pseudo_id', 'study', 'assigned_arm', 'cid_user_display', 'consented', 'assigned_at'] list_display = ['pseudo_id', 'study', 'assigned_arm', 'cid_user_display', 'consented', 'assigned_at']
list_filter = ['study', 'assigned_arm', 'consented', 'created_at'] list_filter = ['study', 'assigned_arm', 'consented', 'created_at']
search_fields = ['pseudo_id', 'study__slug', 'cid_user__cid', 'email'] search_fields = ['pseudo_id', 'study__name', 'cid_user__cid', 'email']
readonly_fields = ['created_at', 'updated_at', 'cid_user_info'] readonly_fields = ['created_at', 'updated_at', 'cid_user_info']
fieldsets = ( fieldsets = (
('Study & ID', { ('Study & ID', {
+1 -3
View File
@@ -26,7 +26,6 @@ class ResearchStudyForm(ModelForm):
model = ResearchStudy model = ResearchStudy
fields = [ fields = [
"name", "name",
"slug",
"description", "description",
"active", "active",
"randomisation_mode", "randomisation_mode",
@@ -48,7 +47,6 @@ class ResearchStudyForm(ModelForm):
Fieldset( Fieldset(
'Study Details', 'Study Details',
'name', 'name',
'slug',
'description', 'description',
'active', 'active',
), ),
@@ -85,7 +83,7 @@ class ResearchStudyArmForm(ModelForm):
"required_previous_arm", "required_previous_arm",
] ]
def __init__(self, study=None, *args, **kwargs): def __init__(self, *args, study=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_method = 'post' self.helper.form_method = 'post'
@@ -0,0 +1,17 @@
# Generated by Django 5.1.4 on 2026-05-12 20:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('research', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='researchstudy',
name='slug',
),
]
+1 -5
View File
@@ -1,13 +1,10 @@
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from generic.mixins import AuthorMixin from generic.mixins import AuthorMixin
from generic.models import CidUser from generic.models import CidUser
from atlas.models import CaseCollection from atlas.models import CaseCollection
from loguru import logger
class ResearchStudy(models.Model, AuthorMixin): class ResearchStudy(models.Model, AuthorMixin):
@@ -24,7 +21,6 @@ class ResearchStudy(models.Model, AuthorMixin):
BULK_ONLY = "BULK", _("Bulk generation only") BULK_ONLY = "BULK", _("Bulk generation only")
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
slug = models.SlugField(max_length=100, unique=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
@@ -222,7 +218,7 @@ class ResearchParticipant(models.Model):
verbose_name_plural = "Research Participants" verbose_name_plural = "Research Participants"
def __str__(self): def __str__(self):
return f"{self.study.slug}:{self.pseudo_id}" return f"{self.study.pk}:{self.pseudo_id}"
def is_prerequisite_satisfied(self): def is_prerequisite_satisfied(self):
"""Check if participant has completed prerequisite arm(s) for current assignment.""" """Check if participant has completed prerequisite arm(s) for current assignment."""
@@ -6,7 +6,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h2>{{ study.name }}</h2> <h2>{{ study.name }}</h2>
<p class="text-muted mb-0">Slug: <code>{{ study.slug }}</code> &bull; <p class="text-muted mb-0">Study ID: <code>{{ study.pk }}</code> &bull;
{{ study.get_generation_mode_display }} &bull; {{ study.get_generation_mode_display }} &bull;
{{ study.get_randomisation_mode_display }} {{ study.get_randomisation_mode_display }}
</p> </p>
@@ -23,7 +23,7 @@
<div class="card card-body bg-dark border-secondary mb-4"> <div class="card card-body bg-dark border-secondary mb-4">
<h5 class="mb-2">Participant Entry URL Template</h5> <h5 class="mb-2">Participant Entry URL Template</h5>
<p class="small text-muted mb-2">Append the pseudo-anonymised ID to this base URL to share with participants:</p> <p class="small text-muted mb-2">Append the pseudo-anonymised ID to this base URL to share with participants:</p>
<code>{{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.slug 'PSEUDO_ID' %}</code> <code>{{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.pk 'PSEUDO_ID' %}</code>
<div class="mt-2"> <div class="mt-2">
<small class="text-muted"> <small class="text-muted">
Participants use CID + passcode generated at intake to access their results. Participants use CID + passcode generated at intake to access their results.
+2 -2
View File
@@ -13,7 +13,7 @@
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Slug</th> <th>ID</th>
<th>Status</th> <th>Status</th>
<th>Randomisation</th> <th>Randomisation</th>
<th>Participants</th> <th>Participants</th>
@@ -24,7 +24,7 @@
{% for study in studies %} {% for study in studies %}
<tr> <tr>
<td>{{ study.name }}</td> <td>{{ study.name }}</td>
<td><code>{{ study.slug }}</code></td> <td><code>{{ study.pk }}</code></td>
<td> <td>
{% if study.active %} {% if study.active %}
<span class="badge bg-success">Active</span> <span class="badge bg-success">Active</span>
+1 -1
View File
@@ -19,5 +19,5 @@ urlpatterns = [
path("<int:pk>/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"), path("<int:pk>/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"),
# Public participant entry (no login required) # Public participant entry (no login required)
path("run/<slug:slug>/<str:pseudo_id>/", views.participant_entry, name="participant_entry"), path("run/<int:study_id>/<str:pseudo_id>/", views.participant_entry, name="participant_entry"),
] ]
+14 -14
View File
@@ -8,7 +8,6 @@ from django.db import transaction
from django.http import HttpResponse, JsonResponse, Http404 from django.http import HttpResponse, JsonResponse, Http404
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.contrib import messages from django.contrib import messages
@@ -124,7 +123,7 @@ def _ensure_research_participant_cid(participant: ResearchParticipant) -> CidUse
created_cid = CidUser.objects.create( created_cid = CidUser.objects.create(
cid=next_cid, cid=next_cid,
passcode=get_random_string(8), passcode=get_random_string(8),
name=f"study:{participant.study.slug}:{participant.pseudo_id}", name=f"study:{participant.study_id}:{participant.pseudo_id}",
active=True, active=True,
) )
break break
@@ -194,7 +193,6 @@ def _build_study_export_rows(study: ResearchStudy):
rows.append( rows.append(
{ {
"study_id": study.pk, "study_id": study.pk,
"study_slug": study.slug,
"study_name": study.name, "study_name": study.name,
"pseudo_id": participant.pseudo_id, "pseudo_id": participant.pseudo_id,
"candidate_group": participant.candidate_group, "candidate_group": participant.candidate_group,
@@ -282,9 +280,9 @@ def study_detail(request, pk: int):
if not _user_can_manage_research_study(study, request.user): if not _user_can_manage_research_study(study, request.user):
raise PermissionDenied raise PermissionDenied
arm_form = ResearchStudyArmForm() if request.method != "POST" else None arm_form = ResearchStudyArmForm(study=study) if request.method != "POST" else None
if request.method == "POST" and "add_arm" in request.POST: if request.method == "POST" and "add_arm" in request.POST:
arm_form = ResearchStudyArmForm(request.POST) arm_form = ResearchStudyArmForm(request.POST, study=study)
if arm_form.is_valid(): if arm_form.is_valid():
arm = arm_form.save(commit=False) arm = arm_form.save(commit=False)
arm.study = study arm.study = study
@@ -344,7 +342,7 @@ def study_export_csv(request, pk: int):
rows = _build_study_export_rows(study) rows = _build_study_export_rows(study)
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="study_{study.slug}_results.csv"' response["Content-Disposition"] = f'attachment; filename="study_{study.pk}_results.csv"'
if rows: if rows:
writer = csv.DictWriter(response, fieldnames=list(rows[0].keys())) writer = csv.DictWriter(response, fieldnames=list(rows[0].keys()))
@@ -464,19 +462,21 @@ def study_bulk_generate(request, pk: int):
# Participant Entry & Access # Participant Entry & Access
# ============================================================================ # ============================================================================
def participant_entry(request, slug: str, pseudo_id: str): def participant_entry(request, study_id: int, pseudo_id: str):
"""Participant landing page with on-site randomisation and intake form.""" """Participant landing page with on-site randomisation and intake form."""
study = get_object_or_404(ResearchStudy, slug=slug) study = get_object_or_404(ResearchStudy, pk=study_id)
if not study.active and not (request.user.is_authenticated and _user_can_manage_research_study(study, request.user)): if not study.active and not (request.user.is_authenticated and _user_can_manage_research_study(study, request.user)):
raise Http404("Study not available") raise Http404("Study not available")
# Check generation mode # Check generation mode
if study.generation_mode == ResearchStudy.GenerationMode.BULK_ONLY: if study.generation_mode == ResearchStudy.GenerationMode.BULK_ONLY:
if not request.user.is_authenticated or not _user_can_manage_research_study(study, request.user): # Try to get existing participant
# Try to get existing participant try:
try: participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id)
participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id) except ResearchParticipant.DoesNotExist:
except ResearchParticipant.DoesNotExist: if request.user.is_authenticated and _user_can_manage_research_study(study, request.user):
participant = ResearchParticipant.objects.create(study=study, pseudo_id=pseudo_id)
else:
raise Http404("Participant not found. Contact study administrator.") raise Http404("Participant not found. Contact study administrator.")
else: else:
# AUTO_ON_ACCESS: create if doesn't exist # AUTO_ON_ACCESS: create if doesn't exist
@@ -523,7 +523,7 @@ def participant_entry(request, slug: str, pseudo_id: str):
messages.success(request, "Intake complete. You can now start the study.") messages.success(request, "Intake complete. You can now start the study.")
return redirect( return redirect(
"research:participant_entry", "research:participant_entry",
slug=study.slug, study_id=study.pk,
pseudo_id=participant.pseudo_id, pseudo_id=participant.pseudo_id,
) )
else: else:
+5 -4
View File
@@ -1,5 +1,6 @@
{% load django_tables2 %} {% load django_tables2 %}
{% load i18n l10n %} {% load i18n l10n %}
{% load help_tags %}
{% block table-wrapper %} {% block table-wrapper %}
<div class="table-container"> <div class="table-container">
{% if table.caption %} {% if table.caption %}
@@ -14,7 +15,7 @@
{% for column in table.columns %} {% for column in table.columns %}
<th {{ column.attrs.th.as_html }}> <th {{ column.attrs.th.as_html }}>
{% if column.orderable %} {% if column.orderable %}
<a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a> <a href="{% update_query_param table.prefixed_order_by_field column.order_by_alias.next %}">{{ column.header }}</a>
{% else %} {% else %}
{{ column.header }} {{ column.header }}
{% endif %} {% endif %}
@@ -64,7 +65,7 @@
{% if table.page.has_previous %} {% if table.page.has_previous %}
{% block pagination.previous %} {% block pagination.previous %}
<li class="previous page-item"> <li class="previous page-item">
<a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}" class="page-link"> <a href="{% update_query_param table.prefixed_page_field table.page.previous_page_number %}" class="page-link">
<span aria-hidden="true">&laquo;</span> <span aria-hidden="true">&laquo;</span>
{% trans 'previous' %} {% trans 'previous' %}
</a> </a>
@@ -75,7 +76,7 @@
{% block pagination.range %} {% block pagination.range %}
{% for p in table.page|table_page_range:table.paginator %} {% for p in table.page|table_page_range:table.paginator %}
<li class="page-item{% if table.page.number == p %} active{% endif %}"> <li class="page-item{% if table.page.number == p %} active{% endif %}">
<a class="page-link" {% if p != '...' %}href="{% querystring table.prefixed_page_field=p %}"{% endif %}> <a class="page-link" {% if p != '...' %}href="{% update_query_param table.prefixed_page_field p %}"{% endif %}>
{{ p }} {{ p }}
</a> </a>
</li> </li>
@@ -85,7 +86,7 @@
{% if table.page.has_next %} {% if table.page.has_next %}
{% block pagination.next %} {% block pagination.next %}
<li class="next page-item"> <li class="next page-item">
<a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}" class="page-link"> <a href="{% update_query_param table.prefixed_page_field table.page.next_page_number %}" class="page-link">
{% trans 'next' %} {% trans 'next' %}
<span aria-hidden="true">&raquo;</span> <span aria-hidden="true">&raquo;</span>
</a> </a>