diff --git a/.coverage b/.coverage index 117fc844..6d679bb3 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.env.example b/.env.example index 265942af..60f344c1 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,4 @@ POSTGRES_PASSWORD=password OIDC_RP_CLIENT_PRIVATE_KEY="MYSUPERSECRETPRIVATEKEY" OIDC_RP_CLIENT_ID="lcrc" OIDC_OP_FQDN="https://example.com" +NHS_LOGIN_SETTINGS_URL="https://settings.example.com" diff --git a/lung_cancer_screening/core/jinja2/layout.jinja b/lung_cancer_screening/core/jinja2/layout.jinja index 0268f944..f26fbdc8 100644 --- a/lung_cancer_screening/core/jinja2/layout.jinja +++ b/lung_cancer_screening/core/jinja2/layout.jinja @@ -18,11 +18,16 @@ }, "account": { "items": [ + { + "text": request.user.full_name, + "href": NHS_LOGIN_SETTINGS_URL, + "icon": True + }, { "text": "Log out", "href": url("questions:logout") } - ] + ] if request.user.is_authenticated else [] } }) }} {% endblock header %} diff --git a/lung_cancer_screening/jinja2_env.py b/lung_cancer_screening/jinja2_env.py index 9df59df2..5f021072 100644 --- a/lung_cancer_screening/jinja2_env.py +++ b/lung_cancer_screening/jinja2_env.py @@ -26,7 +26,12 @@ def environment(**options): ) env.globals.update( - {"static": static, "url": reverse, "STATIC_URL": settings.STATIC_URL} + { + "static": static, + "url": reverse, + "STATIC_URL": settings.STATIC_URL, + "NHS_LOGIN_SETTINGS_URL": settings.NHS_LOGIN_SETTINGS_URL, + } ) env.filters.update( diff --git a/lung_cancer_screening/questions/auth.py b/lung_cancer_screening/questions/auth.py index 9c753ab2..6f27a36f 100644 --- a/lung_cancer_screening/questions/auth.py +++ b/lung_cancer_screening/questions/auth.py @@ -18,7 +18,7 @@ class NHSLoginOIDCBackend(OIDCAuthenticationBackend): """ Custom OIDC authentication backend that uses private key JWT for client authentication instead of client secret. - Uses NHS number as the username field. + Uses sub as the username field. """ def filter_users_by_claims(self, claims): @@ -34,21 +34,27 @@ def filter_users_by_claims(self, claims): def create_user(self, claims): user_class = get_user_model() - nhs_number = claims.get('nhs_number') - if not nhs_number: - raise ValueError("Missing 'nhs_number' claim in OIDC token") + sub = claims.get('sub') + if not sub: + raise ValueError("Missing 'sub' claim in OIDC token") - email = claims.get('email') return user_class.objects.create_user( - nhs_number=nhs_number, - email=email + sub=claims.get('sub'), + nhs_number=claims.get('nhs_number'), + email=claims.get('email'), + given_name=claims.get('given_name'), + family_name=claims.get('family_name'), ) def update_user(self, user, claims): - email = claims.get('email') - if email and user.email != email: - user.email = email - user.save() + user.sub = claims.get('sub'), + user.nhs_number = claims.get('nhs_number') + user.email = claims.get('email') + user.given_name = claims.get('given_name') + user.family_name = claims.get('family_name') + + user.save() + return user def _create_client_assertion(self): diff --git a/lung_cancer_screening/questions/migrations/0001_initial.py b/lung_cancer_screening/questions/migrations/0001_initial.py index 2ca81bd6..2b392bc0 100644 --- a/lung_cancer_screening/questions/migrations/0001_initial.py +++ b/lung_cancer_screening/questions/migrations/0001_initial.py @@ -1,6 +1,10 @@ -# Generated by Django 5.2.4 on 2025-09-03 11:05 +# Generated by Django 5.2.11 on 2026-02-12 11:31 +import django.contrib.postgres.fields +import django.core.validators import django.db.models.deletion +import lung_cancer_screening.questions.models.validators.singleton_option +from django.conf import settings from django.db import migrations, models @@ -13,22 +17,313 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Participant', + name='User', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('unique_id', models.CharField(max_length=255, unique=True)), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('sub', models.CharField(max_length=255, unique=True)), + ('nhs_number', models.CharField(max_length=10, unique=True)), + ('given_name', models.CharField(max_length=255)), + ('family_name', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=254)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + }, ), migrations.CreateModel( - name='QuestionnaireResponse', + name='ResponseSet', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='RespiratoryConditionsResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('P', 'Pneumonia'), ('E', 'Emphysema'), ('B', 'Bronchitis'), ('T', 'Tuberculosis (TB)'), ('C', 'Chronic obstructive pulmonary disease (COPD)'), ('N', 'No, I have not had any of these respiratory conditions')], max_length=1), size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='respiratory_conditions_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RelativesAgeWhenDiagnosedResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(choices=[('Y', 'Yes, they were younger than 60'), ('N', 'No, they were 60 or older'), ('U', 'I do not know')], max_length=1)), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='relatives_age_when_diagnosed', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PeriodsWhenYouStoppedSmokingResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.BooleanField()), + ('duration_years', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1, message='The number of years you stopped smoking for must be at least 1')])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='periods_when_you_stopped_smoking_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HeightResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('metric', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1397, message='Height must be between 139.7cm and 243.8 cm'), django.core.validators.MaxValueValidator(2438, message='Height must be between 139.7cm and 243.8 cm')])), + ('imperial', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(55, message='Height must be between 4 feet 7 inches and 8 feet'), django.core.validators.MaxValueValidator(96, message='Height must be between 4 feet 7 inches and 8 feet')])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='height_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HaveYouEverSmokedResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.IntegerField(choices=[(0, 'Yes, I currently smoke'), (1, 'Yes, I used to smoke'), (2, 'Yes, but I have smoked fewer than 100 cigarettes in my lifetime'), (3, 'No, I have never smoked')])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='have_you_ever_smoked_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='GenderResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(choices=[('F', 'Female'), ('M', 'Male'), ('N', 'Non-binary'), ('P', 'Prefer not to say'), ('G', 'How I describe myself may not match my GP record')], max_length=1)), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='gender_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FamilyHistoryLungCancerResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(choices=[('Y', 'Yes'), ('N', 'No'), ('U', 'I do not know')], max_length=1)), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='family_history_lung_cancer', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='EthnicityResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(choices=[('A', 'Asian or Asian British'), ('B', 'Black, African, Caribbean or Black British'), ('M', 'Mixed or multiple ethnic groups'), ('W', 'White'), ('O', 'Other ethnic group'), ('N', 'Prefer not to say')], max_length=1)), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ethnicity_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='EducationResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('X', 'I finished school before the age of 15'), ('G', 'GCSEs'), ('A', 'A-levels'), ('F', 'Further education'), ('B', "Bachelor's degree"), ('P', 'Postgraduate degree'), ('N', 'Prefer not to say')], max_length=1), size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='education_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DateOfBirthResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('value', models.DateField()), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='date_of_birth_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CheckNeedAppointmentResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.BooleanField()), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='check_need_appointment_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CancerDiagnosisResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.BooleanField()), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cancer_diagnosis_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AsbestosExposureResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.BooleanField()), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='asbestos_exposure_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AgeWhenStartedSmokingResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1, message='The age you started smoking must be between 1 and your current age')])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='age_when_started_smoking_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SexAtBirthResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(choices=[('F', 'Female'), ('M', 'Male'), ('I', 'Intersex')], max_length=1)), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sex_at_birth_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TobaccoSmokingHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.participant')), + ('type', models.CharField(choices=[('Cigarettes', 'Cigarettes'), ('RolledCigarettes', 'Rolled cigarettes, or roll-ups'), ('Pipe', 'Pipe'), ('Cigars', 'Cigars'), ('Cigarillos', 'Cigarillos'), ('Shisha', 'Shisha')])), + ('response_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tobacco_smoking_history', to='questions.responseset')), ], ), + migrations.CreateModel( + name='SmokingFrequencyResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.CharField(choices=[('D', 'Daily'), ('W', 'Weekly'), ('M', 'Monthly')], max_length=1)), + ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoking_frequency_response', to='questions.tobaccosmokinghistory')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SmokingCurrentResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.BooleanField()), + ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoking_current_response', to='questions.tobaccosmokinghistory')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SmokedTotalYearsResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.IntegerField(validators=[django.core.validators.MinValueValidator(1, message='The number of years you smoked cigarettes must be at least 1')])), + ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoked_total_years_response', to='questions.tobaccosmokinghistory')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SmokedAmountResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoked_amount_response', to='questions.tobaccosmokinghistory')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='WeightResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('metric', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(254, message='Weight must be between 25.4kg and 317.5kg'), django.core.validators.MaxValueValidator(3175, message='Weight must be between 25.4kg and 317.5kg')])), + ('imperial', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(56, message='Weight must be between 4 stone and 50 stone'), django.core.validators.MaxValueValidator(700, message='Weight must be between 4 stone and 50 stone')])), + ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='weight_response', to='questions.responseset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddConstraint( + model_name='responseset', + constraint=models.UniqueConstraint(condition=models.Q(('submitted_at__isnull', True)), fields=('user',), name='unique_unsubmitted_response_per_user', violation_error_message='An unsubmitted response set already exists for this user'), + ), + migrations.AddConstraint( + model_name='tobaccosmokinghistory', + constraint=models.UniqueConstraint(fields=('response_set', 'type'), name='unique_tobacco_smoking_history_per_response_set', violation_error_message='A tobacco smoking history already exists for this response set and type'), + ), ] diff --git a/lung_cancer_screening/questions/migrations/0002_booleanresponse.py b/lung_cancer_screening/questions/migrations/0002_booleanresponse.py deleted file mode 100644 index f856a39a..00000000 --- a/lung_cancer_screening/questions/migrations/0002_booleanresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.4 on 2025-09-03 16:03 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='BooleanResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.BooleanField()), - ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.participant')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0003_booleanresponse_question_and_more.py b/lung_cancer_screening/questions/migrations/0003_booleanresponse_question_and_more.py deleted file mode 100644 index 81050d0a..00000000 --- a/lung_cancer_screening/questions/migrations/0003_booleanresponse_question_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.4 on 2025-09-04 10:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0002_booleanresponse'), - ] - - operations = [ - migrations.AddField( - model_name='BooleanResponse', - name='question', - field=models.CharField(default='Have you ever smoked?', max_length=255), - preserve_default=False, - ), - migrations.AddField( - model_name='QuestionnaireResponse', - name='question', - field=models.CharField(default='What is your date of birth?', max_length=255), - preserve_default=False, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0004_rename_questionnaireresponse_to_dateresponse.py b/lung_cancer_screening/questions/migrations/0004_rename_questionnaireresponse_to_dateresponse.py deleted file mode 100644 index 73fc8174..00000000 --- a/lung_cancer_screening/questions/migrations/0004_rename_questionnaireresponse_to_dateresponse.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.4 on 2025-09-04 10:10 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0003_booleanresponse_question_and_more'), - ] - - operations = [ - migrations.RenameModel( - old_name='QuestionnaireResponse', - new_name='DateResponse', - ), - - ] diff --git a/lung_cancer_screening/questions/migrations/0005_responseset.py b/lung_cancer_screening/questions/migrations/0005_responseset.py deleted file mode 100644 index 86290ecf..00000000 --- a/lung_cancer_screening/questions/migrations/0005_responseset.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-08 13:27 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0004_rename_questionnaireresponse_to_dateresponse'), - ] - - operations = [ - migrations.CreateModel( - name='ResponseSet', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('have_you_ever_smoked', models.IntegerField(choices=[(0, 'Yes, I currently smoke'), (1, 'Yes, I used to smoke regularly'), (2, 'Yes, but only a few times'), (3, 'No, I have never smoked')], null=True)), - ('date_of_birth', models.DateField(null=True)), - ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.participant')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0006_remove_dateresponse_participant_and_more.py b/lung_cancer_screening/questions/migrations/0006_remove_dateresponse_participant_and_more.py deleted file mode 100644 index 74f89cbb..00000000 --- a/lung_cancer_screening/questions/migrations/0006_remove_dateresponse_participant_and_more.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-08 14:02 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0005_responseset'), - ] - - operations = [ - migrations.DeleteModel( - name='BooleanResponse', - ), - migrations.DeleteModel( - name='DateResponse', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0007_responseset_submitted_and_more.py b/lung_cancer_screening/questions/migrations/0007_responseset_submitted_and_more.py deleted file mode 100644 index 02633af4..00000000 --- a/lung_cancer_screening/questions/migrations/0007_responseset_submitted_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-08 15:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0006_remove_dateresponse_participant_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='submitted', - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name='responseset', - name='date_of_birth', - field=models.DateField(blank=True, null=True), - ), - migrations.AlterField( - model_name='responseset', - name='have_you_ever_smoked', - field=models.IntegerField(blank=True, choices=[(0, 'Yes, I currently smoke'), (1, 'Yes, I used to smoke regularly'), (2, 'Yes, but only a few times'), (3, 'No, I have never smoked')], null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0008_remove_responseset_submitted_and_more.py b/lung_cancer_screening/questions/migrations/0008_remove_responseset_submitted_and_more.py deleted file mode 100644 index 19c7c766..00000000 --- a/lung_cancer_screening/questions/migrations/0008_remove_responseset_submitted_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-09 09:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0007_responseset_submitted_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='responseset', - name='submitted', - ), - migrations.AddField( - model_name='responseset', - name='submitted_at', - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0009_responseset_height_and_more.py b/lung_cancer_screening/questions/migrations/0009_responseset_height_and_more.py deleted file mode 100644 index e890a04e..00000000 --- a/lung_cancer_screening/questions/migrations/0009_responseset_height_and_more.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-06 15:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0008_remove_responseset_submitted_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='height', - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0010_responseset_height_imperial_alter_responseset_height_and_more.py b/lung_cancer_screening/questions/migrations/0010_responseset_height_imperial_alter_responseset_height_and_more.py deleted file mode 100644 index af74cddb..00000000 --- a/lung_cancer_screening/questions/migrations/0010_responseset_height_imperial_alter_responseset_height_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-17 13:52 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0009_responseset_height_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='height_imperial', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='responseset', - name='height', - field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1397, message='Height must be between 139.7cm and 243.8 cm'), django.core.validators.MaxValueValidator(2438, message='Height must be between 139.7cm and 243.8 cm')]), - ), - migrations.AddConstraint( - model_name='responseset', - constraint=models.UniqueConstraint(condition=models.Q(('submitted_at__isnull', True)), fields=('participant',), name='unique_unsubmitted_response_per_participant', violation_error_message='An unsubmitted response set already exists for this participant'), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0011_responseset_weight_metric_and_more.py b/lung_cancer_screening/questions/migrations/0011_responseset_weight_metric_and_more.py deleted file mode 100644 index f0b854e5..00000000 --- a/lung_cancer_screening/questions/migrations/0011_responseset_weight_metric_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-21 13:13 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0010_responseset_height_imperial_alter_responseset_height_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='weight_metric', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='responseset', - name='height_imperial', - field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(55, message='Height must be between 4 feet 7 inches and 8 feet'), django.core.validators.MaxValueValidator(96, message='Height must be between 4 feet 7 inches and 8 feet')]), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0012_responseset_weight_imperial_and_more.py b/lung_cancer_screening/questions/migrations/0012_responseset_weight_imperial_and_more.py deleted file mode 100644 index a3c8297f..00000000 --- a/lung_cancer_screening/questions/migrations/0012_responseset_weight_imperial_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-27 12:36 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0011_responseset_weight_metric_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='weight_imperial', - field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(56, message='Weight must be between 4 stone and 50 stone'), django.core.validators.MaxValueValidator(700, message='Weight must be between 4 stone and 50 stone')]), - ), - migrations.AlterField( - model_name='responseset', - name='weight_metric', - field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(254, message='Weight must be between 25.4kg and 317.5kg'), django.core.validators.MaxValueValidator(3175, message='Weight must be between 25.4kg and 317.5kg')]), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0013_responseset_sex_at_birth.py b/lung_cancer_screening/questions/migrations/0013_responseset_sex_at_birth.py deleted file mode 100644 index adf0c720..00000000 --- a/lung_cancer_screening/questions/migrations/0013_responseset_sex_at_birth.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 11:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0012_responseset_weight_imperial_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='sex_at_birth', - field=models.CharField(blank=True, choices=[('F', 'Female'), ('M', 'Male')], max_length=1, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0014_responseset_gender.py b/lung_cancer_screening/questions/migrations/0014_responseset_gender.py deleted file mode 100644 index d3703839..00000000 --- a/lung_cancer_screening/questions/migrations/0014_responseset_gender.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-31 10:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0013_responseset_sex_at_birth'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='gender', - field=models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('N', 'Non-binary'), ('P', 'Prefer not to say'), ('G', 'How I describe myself may not match my GP record')], max_length=1, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0015_responseset_ethnicity_alter_responseset_gender.py b/lung_cancer_screening/questions/migrations/0015_responseset_ethnicity_alter_responseset_gender.py deleted file mode 100644 index cc15d032..00000000 --- a/lung_cancer_screening/questions/migrations/0015_responseset_ethnicity_alter_responseset_gender.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-03 14:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0014_responseset_gender'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='ethnicity', - field=models.CharField(blank=True, choices=[('A', 'Asian or Asian British'), ('B', 'Black, African, Caribbean or Black British'), ('M', 'Mixed or multiple ethnic groups'), ('W', 'White'), ('O', 'Other ethnic group'), ('N', "I'd prefer not to say")], max_length=1, null=True), - ), - migrations.AlterField( - model_name='responseset', - name='gender', - field=models.CharField(blank=True, choices=[('F', 'Female'), ('M', 'Male'), ('N', 'Non-binary'), ('P', 'Prefer not to say'), ('G', 'How I describe myself may not match my GP record')], max_length=1, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0016_responseset_asbestos_exposure.py b/lung_cancer_screening/questions/migrations/0016_responseset_asbestos_exposure.py deleted file mode 100644 index 7afaef79..00000000 --- a/lung_cancer_screening/questions/migrations/0016_responseset_asbestos_exposure.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 08:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0015_responseset_ethnicity_alter_responseset_gender'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='asbestos_exposure', - field=models.CharField(blank=True, choices=[('Y', 'Yes'), ('N', 'No')], max_length=1, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0017_alter_responseset_asbestos_exposure.py b/lung_cancer_screening/questions/migrations/0017_alter_responseset_asbestos_exposure.py deleted file mode 100644 index e76f04cd..00000000 --- a/lung_cancer_screening/questions/migrations/0017_alter_responseset_asbestos_exposure.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-12 11:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0016_responseset_asbestos_exposure'), - ] - - operations = [ - migrations.AlterField( - model_name='responseset', - name='asbestos_exposure', - field=models.BooleanField(blank=True, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0018_responseset_respiratory_conditions.py b/lung_cancer_screening/questions/migrations/0018_responseset_respiratory_conditions.py deleted file mode 100644 index 18c0905f..00000000 --- a/lung_cancer_screening/questions/migrations/0018_responseset_respiratory_conditions.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 11:20 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0017_alter_responseset_asbestos_exposure'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='respiratory_conditions', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('P', 'Pneumonia'), ('E', 'Emphysema'), ('B', 'Chronic bronchitis'), ('T', 'Tuberculosis (TB)'), ('C', 'Chronic obstructive pulmonary disease (COPD)'), ('N', 'None of the above')], max_length=1), blank=True, null=True, size=None), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0019_user.py b/lung_cancer_screening/questions/migrations/0019_user.py deleted file mode 100644 index d364295b..00000000 --- a/lung_cancer_screening/questions/migrations/0019_user.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-03 16:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0018_responseset_respiratory_conditions'), - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('nhs_number', models.CharField(max_length=10, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - }, - ) - ] diff --git a/lung_cancer_screening/questions/migrations/0020_responseset_user_and_more.py b/lung_cancer_screening/questions/migrations/0020_responseset_user_and_more.py deleted file mode 100644 index bff208c4..00000000 --- a/lung_cancer_screening/questions/migrations/0020_responseset_user_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-09 16:06 - -import django.contrib.postgres.fields -import django.db.models.deletion -import lung_cancer_screening.questions.models.response_set -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0019_user'), - ] - - operations = [ - migrations.AddField( - model_name='responseset', - name='user', - field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - preserve_default=False, - ), - migrations.AlterField( - model_name='responseset', - name='have_you_ever_smoked', - field=models.IntegerField(blank=True, choices=[(0, 'Yes, I currently smoke'), (1, 'Yes, I used to smoke'), (2, 'Yes, but I have smoked fewer than 100 cigarettes in my lifetime'), (3, 'No, I have never smoked')], null=True), - ), - migrations.AlterField( - model_name='responseset', - name='respiratory_conditions', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('P', 'Pneumonia'), ('E', 'Emphysema'), ('B', 'Bronchitis'), ('T', 'Tuberculosis (TB)'), ('C', 'Chronic obstructive pulmonary disease (COPD)'), ('N', 'No, I have not had any of these respiratory conditions')], max_length=1), blank=True, null=True, size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option]), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0021_remove_responseset_participant_delete_participant.py b/lung_cancer_screening/questions/migrations/0021_remove_responseset_participant_delete_participant.py deleted file mode 100644 index a91f28aa..00000000 --- a/lung_cancer_screening/questions/migrations/0021_remove_responseset_participant_delete_participant.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-09 16:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0020_responseset_user_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='responseset', - name='participant', - ), - migrations.DeleteModel( - name='Participant', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0022_remove_responseset_unique_unsubmitted_response_per_participant_and_more.py b/lung_cancer_screening/questions/migrations/0022_remove_responseset_unique_unsubmitted_response_per_participant_and_more.py deleted file mode 100644 index 576515dd..00000000 --- a/lung_cancer_screening/questions/migrations/0022_remove_responseset_unique_unsubmitted_response_per_participant_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-09 16:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0021_remove_responseset_participant_delete_participant'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='responseset', - name='unique_unsubmitted_response_per_participant', - ), - migrations.AddConstraint( - model_name='responseset', - constraint=models.UniqueConstraint(condition=models.Q(('submitted_at__isnull', True)), fields=('user',), name='unique_unsubmitted_response_per_user', violation_error_message='An unsubmitted response set already exists for this user'), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0023_rename_height_responseset_height_metric.py b/lung_cancer_screening/questions/migrations/0023_rename_height_responseset_height_metric.py deleted file mode 100644 index 8482e558..00000000 --- a/lung_cancer_screening/questions/migrations/0023_rename_height_responseset_height_metric.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-11 16:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0022_remove_responseset_unique_unsubmitted_response_per_participant_and_more'), - ] - - operations = [ - migrations.RenameField( - model_name='responseset', - old_name='height', - new_name='height_metric', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0024_add_email_to_user.py b/lung_cancer_screening/questions/migrations/0024_add_email_to_user.py deleted file mode 100644 index 8bfbc5cc..00000000 --- a/lung_cancer_screening/questions/migrations/0024_add_email_to_user.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-17 15:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0023_rename_height_responseset_height_metric'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='email', - field=models.EmailField(blank=True, max_length=254, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0025_haveyoueversmokedresponse.py b/lung_cancer_screening/questions/migrations/0025_haveyoueversmokedresponse.py deleted file mode 100644 index 9a4f75ec..00000000 --- a/lung_cancer_screening/questions/migrations/0025_haveyoueversmokedresponse.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-18 17:15 - -import django.db.models.deletion -from django.db import migrations, models - - -def copy_have_you_ever_smoked_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_haveyoueversmokedresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), have_you_ever_smoked, id - FROM questions_responseset - WHERE have_you_ever_smoked IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_have_you_ever_smoked_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET have_you_ever_smoked = h.value - FROM questions_haveyoueversmokedresponse h - WHERE rs.id = h.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0024_add_email_to_user'), - ] - - operations = [ - migrations.CreateModel( - name='HaveYouEverSmokedResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.IntegerField(choices=[(0, 'Yes, I currently smoke'), (1, 'Yes, I used to smoke'), (2, 'Yes, but I have smoked fewer than 100 cigarettes in my lifetime'), (3, 'No, I have never smoked')])), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_have_you_ever_smoked_data, reverse_copy_have_you_ever_smoked_data), - migrations.RemoveField( - model_name='responseset', - name='have_you_ever_smoked', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0026_asbestosexposureresponse.py b/lung_cancer_screening/questions/migrations/0026_asbestosexposureresponse.py deleted file mode 100644 index 01c3fbf3..00000000 --- a/lung_cancer_screening/questions/migrations/0026_asbestosexposureresponse.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated manually to create AsbestosExposureResponse and copy data - -import django.db.models.deletion -from django.db import migrations, models - - -def copy_asbestos_exposure_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_asbestosexposureresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), asbestos_exposure, id - FROM questions_responseset - WHERE asbestos_exposure IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_asbestos_exposure_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET asbestos_exposure = a.value - FROM questions_asbestosexposureresponse a - WHERE rs.id = a.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0025_haveyoueversmokedresponse'), - ] - - operations = [ - migrations.CreateModel( - name='AsbestosExposureResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.BooleanField()), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='asbestos_exposure_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_asbestos_exposure_data, reverse_copy_asbestos_exposure_data), - migrations.RemoveField( - model_name='responseset', - name='asbestos_exposure', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0027_dateofbirthresponse.py b/lung_cancer_screening/questions/migrations/0027_dateofbirthresponse.py deleted file mode 100644 index 7440245a..00000000 --- a/lung_cancer_screening/questions/migrations/0027_dateofbirthresponse.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated manually to create DateOfBirthResponse and copy data - -import django.db.models.deletion -from django.db import migrations, models - - -def copy_date_of_birth_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_dateofbirthresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), date_of_birth, id - FROM questions_responseset - WHERE date_of_birth IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_date_of_birth_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET date_of_birth = d.value - FROM questions_dateofbirthresponse d - WHERE rs.id = d.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0026_asbestosexposureresponse'), - ] - - operations = [ - migrations.CreateModel( - name='DateOfBirthResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.DateField()), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='date_of_birth_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_date_of_birth_data, reverse_copy_date_of_birth_data), - migrations.RemoveField( - model_name='responseset', - name='date_of_birth', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0028_ethnicityresponse.py b/lung_cancer_screening/questions/migrations/0028_ethnicityresponse.py deleted file mode 100644 index 914b7fb9..00000000 --- a/lung_cancer_screening/questions/migrations/0028_ethnicityresponse.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated manually to create EthnicityResponse and copy data - -import django.db.models.deletion -from django.db import migrations, models - - -def copy_ethnicity_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_ethnicityresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), ethnicity, id - FROM questions_responseset - WHERE ethnicity IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_ethnicity_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET ethnicity = e.value - FROM questions_ethnicityresponse e - WHERE rs.id = e.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0027_dateofbirthresponse'), - ] - - operations = [ - migrations.CreateModel( - name='EthnicityResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('A', 'Asian or Asian British'), ('B', 'Black, African, Caribbean or Black British'), ('M', 'Mixed or multiple ethnic groups'), ('W', 'White'), ('O', 'Other ethnic group'), ('N', "I'd prefer not to say")], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ethnicity_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_ethnicity_data, reverse_copy_ethnicity_data), - migrations.RemoveField( - model_name='responseset', - name='ethnicity', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0029_genderresponse.py b/lung_cancer_screening/questions/migrations/0029_genderresponse.py deleted file mode 100644 index 5fc534f3..00000000 --- a/lung_cancer_screening/questions/migrations/0029_genderresponse.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated manually to create GenderResponse and copy data - -import django.db.models.deletion -from django.db import migrations, models - - -def copy_gender_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_genderresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), gender, id - FROM questions_responseset - WHERE gender IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_gender_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET gender = g.value - FROM questions_genderresponse g - WHERE rs.id = g.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0028_ethnicityresponse'), - ] - - operations = [ - migrations.CreateModel( - name='GenderResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('F', 'Female'), ('M', 'Male'), ('N', 'Non-binary'), ('P', 'Prefer not to say'), ('G', 'How I describe myself may not match my GP record')], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='gender_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_gender_data, reverse_copy_gender_data), - migrations.RemoveField( - model_name='responseset', - name='gender', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0030_heightresponse.py b/lung_cancer_screening/questions/migrations/0030_heightresponse.py deleted file mode 100644 index 9177cdd5..00000000 --- a/lung_cancer_screening/questions/migrations/0030_heightresponse.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated manually to create HeightResponse and copy data - -import django.core.validators -import django.db.models.deletion -from django.db import migrations, models - - -def copy_height_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_heightresponse (created_at, updated_at, metric, imperial, response_set_id) - SELECT NOW(), NOW(), height_metric, height_imperial, id - FROM questions_responseset - WHERE height_metric IS NOT NULL OR height_imperial IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_height_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET height_metric = h.metric, - height_imperial = h.imperial - FROM questions_heightresponse h - WHERE rs.id = h.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0029_genderresponse'), - ] - - operations = [ - migrations.CreateModel( - name='HeightResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('metric', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1397, message='Height must be between 139.7cm and 243.8 cm'), django.core.validators.MaxValueValidator(2438, message='Height must be between 139.7cm and 243.8 cm')])), - ('imperial', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(55, message='Height must be between 4 feet 7 inches and 8 feet'), django.core.validators.MaxValueValidator(96, message='Height must be between 4 feet 7 inches and 8 feet')])), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='height_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_height_data, reverse_copy_height_data), - migrations.RemoveField( - model_name='responseset', - name='height_metric', - ), - migrations.RemoveField( - model_name='responseset', - name='height_imperial', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0031_respiratoryconditionsresponse.py b/lung_cancer_screening/questions/migrations/0031_respiratoryconditionsresponse.py deleted file mode 100644 index fdb3805c..00000000 --- a/lung_cancer_screening/questions/migrations/0031_respiratoryconditionsresponse.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated manually to create RespiratoryConditionsResponse and copy data - -import django.contrib.postgres.fields -import django.db.models.deletion -import lung_cancer_screening.questions.models.respiratory_conditions_response -from django.db import migrations, models - - -def copy_respiratory_conditions_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_respiratoryconditionsresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), respiratory_conditions, id - FROM questions_responseset - WHERE respiratory_conditions IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_respiratory_conditions_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET respiratory_conditions = r.value - FROM questions_respiratoryconditionsresponse r - WHERE rs.id = r.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0030_heightresponse'), - ] - - operations = [ - migrations.CreateModel( - name='RespiratoryConditionsResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('P', 'Pneumonia'), ('E', 'Emphysema'), ('B', 'Bronchitis'), ('T', 'Tuberculosis (TB)'), ('C', 'Chronic obstructive pulmonary disease (COPD)'), ('N', 'No, I have not had any of these respiratory conditions')], max_length=1), size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option])), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='respiratory_conditions_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_respiratory_conditions_data, reverse_copy_respiratory_conditions_data), - migrations.RemoveField( - model_name='responseset', - name='respiratory_conditions', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0032_sexatbirthresponse.py b/lung_cancer_screening/questions/migrations/0032_sexatbirthresponse.py deleted file mode 100644 index 32a4fdb7..00000000 --- a/lung_cancer_screening/questions/migrations/0032_sexatbirthresponse.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated manually to create SexAtBirthResponse and copy data - -import django.db.models.deletion -from django.db import migrations, models - - -def copy_sex_at_birth_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_sexatbirthresponse (created_at, updated_at, value, response_set_id) - SELECT NOW(), NOW(), sex_at_birth, id - FROM questions_responseset - WHERE sex_at_birth IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_sex_at_birth_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET sex_at_birth = s.value - FROM questions_sexatbirthresponse s - WHERE rs.id = s.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0031_respiratoryconditionsresponse'), - ] - - operations = [ - migrations.CreateModel( - name='SexAtBirthResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('F', 'Female'), ('M', 'Male')], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sex_at_birth_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_sex_at_birth_data, reverse_copy_sex_at_birth_data), - migrations.RemoveField( - model_name='responseset', - name='sex_at_birth', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0033_weightresponse.py b/lung_cancer_screening/questions/migrations/0033_weightresponse.py deleted file mode 100644 index e050512f..00000000 --- a/lung_cancer_screening/questions/migrations/0033_weightresponse.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated manually to create WeightResponse and copy data - -import django.core.validators -import django.db.models.deletion -from django.db import migrations, models - - -def copy_weight_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - INSERT INTO questions_weightresponse (created_at, updated_at, metric, imperial, response_set_id) - SELECT NOW(), NOW(), weight_metric, weight_imperial, id - FROM questions_responseset - WHERE weight_metric IS NOT NULL OR weight_imperial IS NOT NULL - ON CONFLICT (response_set_id) DO NOTHING - """) - - -def reverse_copy_weight_data(apps, schema_editor): - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - UPDATE questions_responseset rs - SET weight_metric = w.metric, - weight_imperial = w.imperial - FROM questions_weightresponse w - WHERE rs.id = w.response_set_id - """) - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0032_sexatbirthresponse'), - ] - - operations = [ - migrations.CreateModel( - name='WeightResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('metric', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(254, message='Weight must be between 25.4kg and 317.5kg'), django.core.validators.MaxValueValidator(3175, message='Weight must be between 25.4kg and 317.5kg')])), - ('imperial', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(56, message='Weight must be between 4 stone and 50 stone'), django.core.validators.MaxValueValidator(700, message='Weight must be between 4 stone and 50 stone')])), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='weight_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.RunPython(copy_weight_data, reverse_copy_weight_data), - migrations.RemoveField( - model_name='responseset', - name='weight_metric', - ), - migrations.RemoveField( - model_name='responseset', - name='weight_imperial', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py b/lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py deleted file mode 100644 index e66a2035..00000000 --- a/lung_cancer_screening/questions/migrations/0034_alter_haveyoueversmokedresponse_response_set.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-30 09:30 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0033_weightresponse'), - ] - - operations = [ - migrations.AlterField( - model_name='haveyoueversmokedresponse', - name='response_set', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='have_you_ever_smoked_response', to='questions.responseset'), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py b/lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py deleted file mode 100644 index 73f7d9a9..00000000 --- a/lung_cancer_screening/questions/migrations/0035_cancerdiagnosisresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2025-12-30 09:41 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0034_alter_haveyoueversmokedresponse_response_set'), - ] - - operations = [ - migrations.CreateModel( - name='CancerDiagnosisResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.BooleanField()), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cancer_diagnosis_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0036_familyhistorylungcancerresponse.py b/lung_cancer_screening/questions/migrations/0036_familyhistorylungcancerresponse.py deleted file mode 100644 index 69a88ccb..00000000 --- a/lung_cancer_screening/questions/migrations/0036_familyhistorylungcancerresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2026-01-02 09:15 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0035_cancerdiagnosisresponse'), - ] - - operations = [ - migrations.CreateModel( - name='FamilyHistoryLungCancerResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('Y', 'Yes'), ('N', 'No'), ('U', 'I do not know')], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='family_history_lung_cancer', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0037_familyhistoryagewhendiagnosedresponse.py b/lung_cancer_screening/questions/migrations/0037_familyhistoryagewhendiagnosedresponse.py deleted file mode 100644 index 79d41feb..00000000 --- a/lung_cancer_screening/questions/migrations/0037_familyhistoryagewhendiagnosedresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2026-01-05 12:20 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0036_familyhistorylungcancerresponse'), - ] - - operations = [ - migrations.CreateModel( - name='FamilyHistoryAgeWhenDiagnosedResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('Y', 'Yes, they were younger than 60'), ('N', 'No, they were 60 or older'), ('U', 'I do not know')], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='family_history_age_when_diagnosed', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0038_relativesagewhendiagnosedresponse_and_more.py b/lung_cancer_screening/questions/migrations/0038_relativesagewhendiagnosedresponse_and_more.py deleted file mode 100644 index dfbf8c0a..00000000 --- a/lung_cancer_screening/questions/migrations/0038_relativesagewhendiagnosedresponse_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.9 on 2026-01-05 15:23 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0037_familyhistoryagewhendiagnosedresponse'), - ] - - operations = [ - migrations.CreateModel( - name='RelativesAgeWhenDiagnosedResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('Y', 'Yes, they were younger than 60'), ('N', 'No, they were 60 or older'), ('U', 'I do not know')], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='relatives_age_when_diagnosed', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.DeleteModel( - name='FamilyHistoryAgeWhenDiagnosedResponse', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0039_checkneedappointmentresponse.py b/lung_cancer_screening/questions/migrations/0039_checkneedappointmentresponse.py deleted file mode 100644 index 1b217bcf..00000000 --- a/lung_cancer_screening/questions/migrations/0039_checkneedappointmentresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2026-01-08 12:11 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0038_relativesagewhendiagnosedresponse_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='CheckNeedAppointmentResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.BooleanField()), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='check_need_appointment_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0040_educationresponse.py b/lung_cancer_screening/questions/migrations/0040_educationresponse.py deleted file mode 100644 index cc1cde9d..00000000 --- a/lung_cancer_screening/questions/migrations/0040_educationresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2026-01-09 13:33 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0039_checkneedappointmentresponse'), - ] - - operations = [ - migrations.CreateModel( - name='EducationResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('X', 'I finished school before the age of 15'), ('G', 'GCSEs'), ('A', 'A-levels'), ('B', "Bachelor's degree"), ('P', 'Postgraduate degree'), ('N', "I'd prefer not to say")], max_length=1)), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='education_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0041_alter_educationresponse_value.py b/lung_cancer_screening/questions/migrations/0041_alter_educationresponse_value.py deleted file mode 100644 index 5f4051e3..00000000 --- a/lung_cancer_screening/questions/migrations/0041_alter_educationresponse_value.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-15 16:41 - -import django.contrib.postgres.fields -import lung_cancer_screening.questions.models.validators.singleton_option -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0040_educationresponse'), - ] - - operations = [ - migrations.AlterField( - model_name='educationresponse', - name='value', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('X', 'I finished school before the age of 15'), ('G', 'GCSEs'), ('A', 'A-levels'), ('B', "Bachelor's degree"), ('P', 'Postgraduate degree'), ('N', "I'd prefer not to say")], max_length=1), size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option]), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0042_alter_educationresponse_value.py b/lung_cancer_screening/questions/migrations/0042_alter_educationresponse_value.py deleted file mode 100644 index e85a18b5..00000000 --- a/lung_cancer_screening/questions/migrations/0042_alter_educationresponse_value.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-19 10:47 - -import django.contrib.postgres.fields -import lung_cancer_screening.questions.models.validators.singleton_option -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0041_alter_educationresponse_value'), - ] - - operations = [ - migrations.AlterField( - model_name='educationresponse', - name='value', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('X', 'I finished school before the age of 15'), ('G', 'GCSEs'), ('A', 'A-levels'), ('F', 'Further education'), ('B', "Bachelor's degree"), ('P', 'Postgraduate degree'), ('N', 'Prefer not to say')], max_length=1), size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option]), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0043_agewhenstartedsmokingresponse.py b/lung_cancer_screening/questions/migrations/0043_agewhenstartedsmokingresponse.py deleted file mode 100644 index 0866db59..00000000 --- a/lung_cancer_screening/questions/migrations/0043_agewhenstartedsmokingresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-15 08:59 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0042_alter_educationresponse_value'), - ] - - operations = [ - migrations.CreateModel( - name='AgeWhenStartedSmokingResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.PositiveIntegerField()), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='age_when_started_smoking_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0044_periodswhenyoustoppedsmokingresponse.py b/lung_cancer_screening/questions/migrations/0044_periodswhenyoustoppedsmokingresponse.py deleted file mode 100644 index 94231637..00000000 --- a/lung_cancer_screening/questions/migrations/0044_periodswhenyoustoppedsmokingresponse.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("questions", "0043_agewhenstartedsmokingresponse"), - ] - - operations = [ - migrations.CreateModel( - name='PeriodsWhenYouStoppedSmokingResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.BooleanField()), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='periods_when_you_stopped_smoking_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0045_periodswhenyoustoppedsmokingresponse_duration_years.py b/lung_cancer_screening/questions/migrations/0045_periodswhenyoustoppedsmokingresponse_duration_years.py deleted file mode 100644 index 366cc48e..00000000 --- a/lung_cancer_screening/questions/migrations/0045_periodswhenyoustoppedsmokingresponse_duration_years.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-21 11:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0044_periodswhenyoustoppedsmokingresponse'), - ] - - operations = [ - migrations.AddField( - model_name='periodswhenyoustoppedsmokingresponse', - name='duration_years', - field=models.IntegerField(blank=True, null=True), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0046_alter_ethnicityresponse_value_and_more.py b/lung_cancer_screening/questions/migrations/0046_alter_ethnicityresponse_value_and_more.py deleted file mode 100644 index 0ed41559..00000000 --- a/lung_cancer_screening/questions/migrations/0046_alter_ethnicityresponse_value_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-21 16:57 - -import django.contrib.postgres.fields -import django.db.models.deletion -import lung_cancer_screening.questions.models.validators.singleton_option -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("questions", "0045_periodswhenyoustoppedsmokingresponse_duration_years"), - ] - - operations = [ - migrations.CreateModel( - name='TypesTobaccoSmokingResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('C', 'Cigarettes'), ('G', 'Cigars'), ('P', 'Pipes'), ('E', 'E-cigarettes or vaping'), ('N', 'None of the above')], max_length=1), size=None, validators=[lung_cancer_screening.questions.models.validators.singleton_option.validate_singleton_option])), - ('response_set', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='types_tobacco_smoking_response', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0047_alter_agewhenstartedsmokingresponse_value_and_more.py b/lung_cancer_screening/questions/migrations/0047_alter_agewhenstartedsmokingresponse_value_and_more.py deleted file mode 100644 index 92bb6aaf..00000000 --- a/lung_cancer_screening/questions/migrations/0047_alter_agewhenstartedsmokingresponse_value_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-27 14:59 - -import django.contrib.postgres.fields -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0046_alter_ethnicityresponse_value_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='agewhenstartedsmokingresponse', - name='value', - field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1, message='The age you started smoking must be between 1 and your current age')]), - ), - migrations.AlterField( - model_name='ethnicityresponse', - name='value', - field=models.CharField(choices=[('A', 'Asian or Asian British'), ('B', 'Black, African, Caribbean or Black British'), ('M', 'Mixed or multiple ethnic groups'), ('W', 'White'), ('O', 'Other ethnic group'), ('N', 'Prefer not to say')], max_length=1), - ), - migrations.AlterField( - model_name='sexatbirthresponse', - name='value', - field=models.CharField(choices=[('F', 'Female'), ('M', 'Male'), ('I', 'Intersex')], max_length=1), - ), - migrations.AlterField( - model_name='typestobaccosmokingresponse', - name='value', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('C', 'Cigarettes'), ('R', 'Rolled cigarettes, or roll-ups'), ('P', 'Pipe'), ('G', 'Cigars'), ('E', 'Cigarillos'), ('S', 'Shisha')], max_length=1), size=None), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0048_tobaccosmokinghistory_and_more.py b/lung_cancer_screening/questions/migrations/0048_tobaccosmokinghistory_and_more.py deleted file mode 100644 index e48661f7..00000000 --- a/lung_cancer_screening/questions/migrations/0048_tobaccosmokinghistory_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-28 10:46 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0047_alter_agewhenstartedsmokingresponse_value_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='TobaccoSmokingHistory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('type', models.CharField(choices=[('Cigarettes', 'Cigarettes'), ('RolledCigarettes', 'Rolled cigarettes, or roll-ups'), ('Pipe', 'Pipe'), ('Cigars', 'Cigars'), ('Cigarillos', 'Cigarillos'), ('Shisha', 'Shisha')])), - ('response_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='types_tobacco_smoking', to='questions.responseset')), - ], - options={ - 'abstract': False, - }, - ), - migrations.DeleteModel( - name='TypesTobaccoSmokingResponse', - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0049_smokedtotalyearsresponse_and_more.py b/lung_cancer_screening/questions/migrations/0049_smokedtotalyearsresponse_and_more.py deleted file mode 100644 index b2e3662a..00000000 --- a/lung_cancer_screening/questions/migrations/0049_smokedtotalyearsresponse_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 5.2.10 on 2026-01-28 15:38 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0048_tobaccosmokinghistory_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='SmokedTotalYearsResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.IntegerField()), - ], - options={ - 'abstract': False, - }, - ), - migrations.AlterField( - model_name='tobaccosmokinghistory', - name='response_set', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tobacco_smoking_history', to='questions.responseset'), - ), - migrations.AddConstraint( - model_name='tobaccosmokinghistory', - constraint=models.UniqueConstraint(fields=('response_set', 'type'), name='unique_tobacco_smoking_history_per_response_set', violation_error_message='A tobacco smoking history already exists for this response set and type'), - ), - migrations.AddField( - model_name='smokedtotalyearsresponse', - name='tobacco_smoking_history', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoked_total_years_response', to='questions.tobaccosmokinghistory'), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0050_alter_periodswhenyoustoppedsmokingresponse_duration_years_and_more.py b/lung_cancer_screening/questions/migrations/0050_alter_periodswhenyoustoppedsmokingresponse_duration_years_and_more.py deleted file mode 100644 index 28172bcf..00000000 --- a/lung_cancer_screening/questions/migrations/0050_alter_periodswhenyoustoppedsmokingresponse_duration_years_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.10 on 2026-02-03 16:30 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0049_smokedtotalyearsresponse_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='periodswhenyoustoppedsmokingresponse', - name='duration_years', - field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1, message='The number of years you stopped smoking for must be at least 1')]), - ), - migrations.AlterField( - model_name='smokedtotalyearsresponse', - name='value', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1, message='The number of years you smoked cigarettes must be at least 1')]), - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0051_smokingcurrentresponse.py b/lung_cancer_screening/questions/migrations/0051_smokingcurrentresponse.py deleted file mode 100644 index 240ec805..00000000 --- a/lung_cancer_screening/questions/migrations/0051_smokingcurrentresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.11 on 2026-02-04 13:59 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0050_alter_periodswhenyoustoppedsmokingresponse_duration_years_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='SmokingCurrentResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.BooleanField()), - ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoking_current_response', to='questions.tobaccosmokinghistory')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0052_smokingfrequencyresponse.py b/lung_cancer_screening/questions/migrations/0052_smokingfrequencyresponse.py deleted file mode 100644 index 53ae82b2..00000000 --- a/lung_cancer_screening/questions/migrations/0052_smokingfrequencyresponse.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.11 on 2026-02-08 18:16 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questions', '0051_smokingcurrentresponse'), - ] - - operations = [ - migrations.CreateModel( - name='SmokingFrequencyResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.CharField(choices=[('D', 'Daily'), ('W', 'Weekly'), ('M', 'Monthly')], max_length=1)), - ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoking_frequency_response', to='questions.tobaccosmokinghistory')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/migrations/0053_smokedamountresponse.py b/lung_cancer_screening/questions/migrations/0053_smokedamountresponse.py deleted file mode 100644 index 7b4deb83..00000000 --- a/lung_cancer_screening/questions/migrations/0053_smokedamountresponse.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.10 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "questions", - "0052_smokingfrequencyresponse", - ), - ] - - operations = [ - migrations.CreateModel( - name='SmokedAmountResponse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('value', models.IntegerField()), - ('tobacco_smoking_history', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='smoked_amount_response', to='questions.tobaccosmokinghistory')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/lung_cancer_screening/questions/models/user.py b/lung_cancer_screening/questions/models/user.py index 84b43206..8bb5fd0e 100644 --- a/lung_cancer_screening/questions/models/user.py +++ b/lung_cancer_screening/questions/models/user.py @@ -7,10 +7,11 @@ class UserManager(BaseUserManager): - def create_user(self, nhs_number, **extra_fields): - if not nhs_number: - raise ValueError('The NHS number must be set') - user = self.model(nhs_number=nhs_number, **extra_fields) + def create_user(self, sub, **extra_fields): + if not sub: + raise ValueError('The sub must be set') + + user = self.model(sub=sub, **extra_fields) # Set an unusable password since AbstractBaseUser requires it user.set_unusable_password() user.save(using=self._db) @@ -18,14 +19,17 @@ def create_user(self, nhs_number, **extra_fields): class User(AbstractBaseUser): + sub = models.CharField(max_length=255, unique=True) nhs_number = models.CharField(max_length=10, unique=True) - email = models.EmailField(blank=True, null=True) + given_name = models.CharField(max_length=255) + family_name = models.CharField(max_length=255) + email = models.EmailField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) objects = UserManager() - USERNAME_FIELD = 'nhs_number' + USERNAME_FIELD = 'sub' REQUIRED_FIELDS = [] def save(self, *args, **kwargs): @@ -46,3 +50,7 @@ def has_recently_submitted_responses(self, excluding=None): def most_recent_response_set(self): return self.responseset_set.order_by('-submitted_at').first() + + @property + def full_name(self): + return f"{self.given_name} {self.family_name}" diff --git a/lung_cancer_screening/questions/tests/factories/user_factory.py b/lung_cancer_screening/questions/tests/factories/user_factory.py index fa8495c9..9328736d 100644 --- a/lung_cancer_screening/questions/tests/factories/user_factory.py +++ b/lung_cancer_screening/questions/tests/factories/user_factory.py @@ -7,5 +7,9 @@ class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User + sub = factory.Sequence(lambda n: f"nhs-login-sub-{n}") nhs_number = factory.Sequence(lambda n: f"9{str(n).zfill(9)}") password = factory.django.Password(None) + email = factory.Faker("email") + given_name = factory.Faker("first_name") + family_name = factory.Faker("last_name") diff --git a/lung_cancer_screening/questions/tests/unit/models/test_user.py b/lung_cancer_screening/questions/tests/unit/models/test_user.py index 7463d47c..36d4b523 100644 --- a/lung_cancer_screening/questions/tests/unit/models/test_user.py +++ b/lung_cancer_screening/questions/tests/unit/models/test_user.py @@ -17,6 +17,9 @@ def test_has_a_valid_factory(self): model.full_clean() + def test_has_sub_as_a_string(self): + self.assertIsInstance(self.user.sub, str) + def test_has_nhs_number_as_a_string(self): self.assertIsInstance( self.user.nhs_number, @@ -45,23 +48,37 @@ def test_has_updated_at_as_a_datetime(self): ) - def test_nhs_number_has_a_max_length_of_10(self): + def test_has_many_response_sets(self): + response_set = self.user.responseset_set.create() + self.assertIn(response_set, list(self.user.responseset_set.all())) + + + def test_is_invalid_without_a_sub(self): + self.user.sub = None + with self.assertRaises(ValidationError) as context: - UserFactory(nhs_number="1"*11) + self.user.full_clean() self.assertIn( - "Ensure this value has at most 10 characters (it has 11).", + "This field cannot be null.", context.exception.messages ) - def test_has_many_response_sets(self): - response_set = self.user.responseset_set.create() - self.assertIn(response_set, list(self.user.responseset_set.all())) + def test_in_invalid_if_sub_is_not_unique(self): + with self.assertRaises(ValidationError) as context: + UserFactory(sub=self.user.sub) + + self.assertIn( + "User with this Sub already exists.", + context.exception.messages + ) - def test_raises_a_validation_error_if_nhs_number_is_null(self): + def test_is_invalid_without_nhs_number(self): + self.user.nhs_number = None + with self.assertRaises(ValidationError) as context: - UserFactory(nhs_number=None) + self.user.full_clean() self.assertIn( "This field cannot be null.", @@ -79,6 +96,53 @@ def test_raises_a_validation_error_if_nhs_number_is_duplicate(self): ) + def test_nhs_number_has_a_max_length_of_10(self): + self.user.nhs_number = "1"*11 + + with self.assertRaises(ValidationError) as context: + self.user.full_clean() + + self.assertIn( + "Ensure this value has at most 10 characters (it has 11).", + context.exception.messages + ) + + + def test_is_invalid_without_a_given_name(self): + self.user.given_name = None + + with self.assertRaises(ValidationError) as context: + self.user.full_clean() + + self.assertIn( + "This field cannot be null.", + context.exception.messages + ) + + def test_is_invalid_without_a_family_name(self): + self.user.family_name = None + + with self.assertRaises(ValidationError) as context: + self.user.full_clean() + + self.assertIn( + "This field cannot be null.", + context.exception.messages[0] + ) + + + def test_is_invalid_without_an_email(self): + self.user.email = None + + with self.assertRaises(ValidationError) as context: + self.user.full_clean() + + self.assertIn( + "This field cannot be null.", + context.exception.messages[0] + ) + + def test_has_recently_submitted_responses_returns_true_if_has_recently_submitted_response_set(self): ResponseSetFactory.create( user=self.user, diff --git a/lung_cancer_screening/questions/tests/unit/test_auth.py b/lung_cancer_screening/questions/tests/unit/test_auth.py index 7ac42315..21306cbc 100644 --- a/lung_cancer_screening/questions/tests/unit/test_auth.py +++ b/lung_cancer_screening/questions/tests/unit/test_auth.py @@ -6,6 +6,7 @@ from cryptography.hazmat.primitives import serialization from ...auth import NHSLoginOIDCBackend +from ...tests.factories.user_factory import UserFactory User = get_user_model() @@ -31,88 +32,71 @@ def setUp(self): encryption_algorithm=serialization.NoEncryption() ).decode('utf-8') + self.claims = { + "sub": "nhs-login-sub-123", + "nhs_number": "1234567890", + "email": "test@example.com", + "given_name": "Jane", + "family_name": "Smith", + } + def test_filter_users_by_claims_for_existing_user(self): - user = User.objects.create_user(nhs_number='1234567890') + user = User.objects.create_user(**self.claims) - claims = {'nhs_number': '1234567890'} - result = self.backend.filter_users_by_claims(claims) + result = self.backend.filter_users_by_claims(self.claims) self.assertEqual(result.count(), 1) self.assertEqual(result.first(), user) def test_filter_users_by_claims_for_non_existent_user(self): - claims = {'nhs_number': '1111111111'} + claims = {**self.claims, "sub": "other-sub-456"} result = self.backend.filter_users_by_claims(claims) self.assertEqual(result.count(), 0) def test_filter_users_by_claims_when_no_claim_is_provided(self): - claims = {} - result = self.backend.filter_users_by_claims(claims) + result = self.backend.filter_users_by_claims({}) self.assertEqual(result.count(), 0) - def test_create_user_when_nhs_number_claim_is_provided(self): - claims = {'nhs_number': '1234567890'} - - user = self.backend.create_user(claims) - - self.assertEqual(user.nhs_number, '1234567890') - def test_create_user_with_email_claim(self): - claims = { - 'nhs_number': '1234567890', - 'email': 'test@example.com' - } + def test_create_user_creates_a_valid_user(self): + user = self.backend.create_user(self.claims) - user = self.backend.create_user(claims) + self.assertEqual(user.sub, self.claims["sub"]) + self.assertEqual(user.nhs_number, self.claims["nhs_number"]) + self.assertEqual(user.email, self.claims["email"]) + self.assertEqual(user.given_name, self.claims["given_name"]) + self.assertEqual(user.family_name, self.claims["family_name"]) - self.assertEqual(user.nhs_number, '1234567890') - self.assertEqual(user.email, 'test@example.com') - def test_create_user_without_nhs_number_raises_error(self): + def test_create_user_without_sub_raises_error(self): claims = {} with self.assertRaises(ValueError) as context: self.backend.create_user(claims) - self.assertIn("Missing 'nhs_number' claim", str(context.exception)) + self.assertIn("Missing 'sub' claim", str(context.exception)) - def test_update_user_returns_user(self): - user = User.objects.create_user(nhs_number='1234567890') - claims = {'nhs_number': '1234567890', 'email': 'test@example.com'} + def test_update_user_updates_the_user(self): + user = UserFactory.create(sub='sub-123', nhs_number='1234567890') + claims = { + 'sub': 'sub-123', + 'nhs_number': '1234567890', + 'email': 'test@example.com', + 'given_name': 'Jane', + 'family_name': 'Smith', + } result = self.backend.update_user(user, claims) self.assertEqual(result, user) self.assertEqual(user.email, 'test@example.com') + self.assertEqual(user.given_name, 'Jane') + self.assertEqual(user.family_name, 'Smith') + self.assertEqual(user.nhs_number, '1234567890') - def test_update_user_updates_email_when_provided(self): - user = User.objects.create_user( - nhs_number='1234567890', - email='old@example.com' - ) - claims = {'nhs_number': '1234567890', 'email': 'new@example.com'} - - result = self.backend.update_user(user, claims) - - user.refresh_from_db() - self.assertEqual(user.email, 'new@example.com') - self.assertEqual(result, user) - - def test_update_user_does_not_update_email_when_not_provided(self): - user = User.objects.create_user( - nhs_number='1234567890', - email='existing@example.com' - ) - claims = {'nhs_number': '1234567890'} - - result = self.backend.update_user(user, claims) - - user.refresh_from_db() - self.assertEqual(user.email, 'existing@example.com') - self.assertEqual(result, user) @patch('lung_cancer_screening.questions.auth.requests.post') def test_get_token_success(self, mock_post): diff --git a/lung_cancer_screening/settings.py b/lung_cancer_screening/settings.py index 59556710..36f1d1dd 100644 --- a/lung_cancer_screening/settings.py +++ b/lung_cancer_screening/settings.py @@ -233,8 +233,9 @@ def list_env(key): # NHS Login requires RS512 for token endpoint authentication # See: https://auth.sandpit.signin.nhs.uk/.well-known/openid-configuration OIDC_RP_SIGN_ALGO = "RS512" -OIDC_RP_SCOPES = "openid profile" +OIDC_RP_SCOPES = "openid profile profile_extended" OIDC_RP_REDIRECT_URI = "/oidc/callback/" +NHS_LOGIN_SETTINGS_URL = environ.get("NHS_LOGIN_SETTINGS_URL") # Authentication backends AUTHENTICATION_BACKENDS = [