From 89e89cef73781fc5ba68afdc0fb6792b1a041a02 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 12 May 2026 21:51:44 +0100 Subject: [PATCH] feat: Remove slug field from ResearchStudy model and update related references --- generic/templatetags/help_tags.py | 21 ++++++++++++++ research/admin.py | 8 +++--- research/forms.py | 4 +-- .../0002_remove_researchstudy_slug.py | 17 +++++++++++ research/models.py | 6 +--- research/templates/research/study_detail.html | 4 +-- research/templates/research/study_list.html | 4 +-- research/urls.py | 2 +- research/views.py | 28 +++++++++---------- templates/django_tables2/bootstrap4.html | 9 +++--- 10 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 research/migrations/0002_remove_researchstudy_slug.py diff --git a/generic/templatetags/help_tags.py b/generic/templatetags/help_tags.py index b20fafa4..bbdb2d10 100644 --- a/generic/templatetags/help_tags.py +++ b/generic/templatetags/help_tags.py @@ -3,6 +3,27 @@ from django.template import Node, TemplateSyntaxError, Variable 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): def __init__(self, nodelist, title, icon, extra_class): self.nodelist = nodelist diff --git a/research/admin.py b/research/admin.py index 535d9de9..dbe88ff8 100644 --- a/research/admin.py +++ b/research/admin.py @@ -5,13 +5,13 @@ from research.models import ResearchStudy, ResearchStudyArm, ResearchParticipant @admin.register(ResearchStudy) 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'] - search_fields = ['name', 'slug', 'description'] + search_fields = ['name', 'description'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( ('Study Information', { - 'fields': ('name', 'slug', 'description', 'active') + 'fields': ('name', 'description', 'active') }), ('Participant Creation', { 'fields': ('generation_mode', 'collect_demographics'), @@ -64,7 +64,7 @@ class ResearchStudyArmAdmin(admin.ModelAdmin): class ResearchParticipantAdmin(admin.ModelAdmin): list_display = ['pseudo_id', 'study', 'assigned_arm', 'cid_user_display', 'consented', 'assigned_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'] fieldsets = ( ('Study & ID', { diff --git a/research/forms.py b/research/forms.py index cd352af8..5b60fb81 100644 --- a/research/forms.py +++ b/research/forms.py @@ -26,7 +26,6 @@ class ResearchStudyForm(ModelForm): model = ResearchStudy fields = [ "name", - "slug", "description", "active", "randomisation_mode", @@ -48,7 +47,6 @@ class ResearchStudyForm(ModelForm): Fieldset( 'Study Details', 'name', - 'slug', 'description', 'active', ), @@ -85,7 +83,7 @@ class ResearchStudyArmForm(ModelForm): "required_previous_arm", ] - def __init__(self, study=None, *args, **kwargs): + def __init__(self, *args, study=None, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_method = 'post' diff --git a/research/migrations/0002_remove_researchstudy_slug.py b/research/migrations/0002_remove_researchstudy_slug.py new file mode 100644 index 00000000..d42a6cce --- /dev/null +++ b/research/migrations/0002_remove_researchstudy_slug.py @@ -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', + ), + ] diff --git a/research/models.py b/research/models.py index 70691f72..25977bc6 100644 --- a/research/models.py +++ b/research/models.py @@ -1,13 +1,10 @@ from django.db import models from django.conf import settings from django.urls import reverse -from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError from generic.mixins import AuthorMixin from generic.models import CidUser from atlas.models import CaseCollection -from loguru import logger class ResearchStudy(models.Model, AuthorMixin): @@ -24,7 +21,6 @@ class ResearchStudy(models.Model, AuthorMixin): BULK_ONLY = "BULK", _("Bulk generation only") name = models.CharField(max_length=255) - slug = models.SlugField(max_length=100, unique=True) description = models.TextField(blank=True) active = models.BooleanField(default=True) @@ -222,7 +218,7 @@ class ResearchParticipant(models.Model): verbose_name_plural = "Research Participants" def __str__(self): - return f"{self.study.slug}:{self.pseudo_id}" + return f"{self.study.pk}:{self.pseudo_id}" def is_prerequisite_satisfied(self): """Check if participant has completed prerequisite arm(s) for current assignment.""" diff --git a/research/templates/research/study_detail.html b/research/templates/research/study_detail.html index c1f61ac5..c7ef871a 100644 --- a/research/templates/research/study_detail.html +++ b/research/templates/research/study_detail.html @@ -6,7 +6,7 @@

{{ study.name }}

-

Slug: {{ study.slug }} • +

Study ID: {{ study.pk }} • {{ study.get_generation_mode_display }} • {{ study.get_randomisation_mode_display }}

@@ -23,7 +23,7 @@
Participant Entry URL Template

Append the pseudo-anonymised ID to this base URL to share with participants:

- {{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.slug 'PSEUDO_ID' %} + {{ request.scheme }}://{{ request.get_host }}{% url 'research:participant_entry' study.pk 'PSEUDO_ID' %}
Participants use CID + passcode generated at intake to access their results. diff --git a/research/templates/research/study_list.html b/research/templates/research/study_list.html index 6f3e4f84..a88737fd 100644 --- a/research/templates/research/study_list.html +++ b/research/templates/research/study_list.html @@ -13,7 +13,7 @@ Name - Slug + ID Status Randomisation Participants @@ -24,7 +24,7 @@ {% for study in studies %} {{ study.name }} - {{ study.slug }} + {{ study.pk }} {% if study.active %} Active diff --git a/research/urls.py b/research/urls.py index 9b85af29..0394f406 100644 --- a/research/urls.py +++ b/research/urls.py @@ -19,5 +19,5 @@ urlpatterns = [ path("/bulk-generate/", views.study_bulk_generate, name="study_bulk_generate"), # Public participant entry (no login required) - path("run///", views.participant_entry, name="participant_entry"), + path("run///", views.participant_entry, name="participant_entry"), ] diff --git a/research/views.py b/research/views.py index de3e48af..4311e1af 100644 --- a/research/views.py +++ b/research/views.py @@ -8,7 +8,6 @@ from django.db import transaction from django.http import HttpResponse, JsonResponse, Http404 from django.urls import reverse from django.utils import timezone -from django.utils.text import slugify from django.utils.crypto import get_random_string from django.contrib import messages @@ -124,7 +123,7 @@ def _ensure_research_participant_cid(participant: ResearchParticipant) -> CidUse created_cid = CidUser.objects.create( cid=next_cid, 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, ) break @@ -194,7 +193,6 @@ def _build_study_export_rows(study: ResearchStudy): rows.append( { "study_id": study.pk, - "study_slug": study.slug, "study_name": study.name, "pseudo_id": participant.pseudo_id, "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): 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: - arm_form = ResearchStudyArmForm(request.POST) + arm_form = ResearchStudyArmForm(request.POST, study=study) if arm_form.is_valid(): arm = arm_form.save(commit=False) arm.study = study @@ -344,7 +342,7 @@ def study_export_csv(request, pk: int): rows = _build_study_export_rows(study) 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: writer = csv.DictWriter(response, fieldnames=list(rows[0].keys())) @@ -464,19 +462,21 @@ def study_bulk_generate(request, pk: int): # 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.""" - 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)): raise Http404("Study not available") # Check generation mode 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: - participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id) - except ResearchParticipant.DoesNotExist: + # Try to get existing participant + try: + participant = ResearchParticipant.objects.get(study=study, pseudo_id=pseudo_id) + 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.") else: # 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.") return redirect( "research:participant_entry", - slug=study.slug, + study_id=study.pk, pseudo_id=participant.pseudo_id, ) else: diff --git a/templates/django_tables2/bootstrap4.html b/templates/django_tables2/bootstrap4.html index ddf81751..079f5d62 100644 --- a/templates/django_tables2/bootstrap4.html +++ b/templates/django_tables2/bootstrap4.html @@ -1,5 +1,6 @@ {% load django_tables2 %} {% load i18n l10n %} +{% load help_tags %} {% block table-wrapper %}
{% if table.caption %} @@ -14,7 +15,7 @@ {% for column in table.columns %} {% if column.orderable %} - {{ column.header }} + {{ column.header }} {% else %} {{ column.header }} {% endif %} @@ -64,7 +65,7 @@ {% if table.page.has_previous %} {% block pagination.previous %}
  • - + {{ p }}
  • @@ -85,7 +86,7 @@ {% if table.page.has_next %} {% block pagination.next %}