diff --git a/config/settings/base.py b/config/settings/base.py index d8a41f94..0f4e6db1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -371,6 +371,8 @@ "gregor_django.gregor_anvil.adapters.UploadWorkspaceAdapter", "gregor_django.gregor_anvil.adapters.CombinedConsortiumDataWorkspaceAdapter", "gregor_django.gregor_anvil.adapters.ReleaseWorkspaceAdapter", + "gregor_django.gregor_anvil.adapters.DCCProcessingWorkspaceAdapter", + "gregor_django.gregor_anvil.adapters.DCCProcessedDataWorkspaceAdapter", ] ANVIL_ACCOUNT_ADAPTER = "gregor_django.gregor_anvil.adapters.AccountAdapter" diff --git a/gregor_django/gregor_anvil/adapters.py b/gregor_django/gregor_anvil/adapters.py index f767205c..62e47330 100644 --- a/gregor_django/gregor_anvil/adapters.py +++ b/gregor_django/gregor_anvil/adapters.py @@ -104,3 +104,29 @@ class ReleaseWorkspaceAdapter(BaseWorkspaceAdapter): workspace_data_model = models.ReleaseWorkspace workspace_data_form_class = forms.ReleaseWorkspaceForm workspace_detail_template_name = "gregor_anvil/releaseworkspace_detail.html" + + +class DCCProcessingWorkspaceAdapter(BaseWorkspaceAdapter): + """Adapter for DCCProcessingWorkspace.""" + + type = "dcc_processing" + name = "DCC processing workspace" + description = "Workspaces used for DCC processing of data." + list_table_class = tables.DCCProcessingWorkspaceTable + workspace_data_model = models.DCCProcessingWorkspace + workspace_data_form_class = forms.DCCProcessingWorkspaceForm + workspace_detail_template_name = "gregor_anvil/dccprocessingworkspace_detail.html" + + +class DCCProcessedDataWorkspaceAdapter(BaseWorkspaceAdapter): + """Adapter for DCCProcessedDataWorkspace.""" + + type = "dcc_processed_data" + name = "DCC processed data workspace" + description = "Workspaces containing data processed by the DCC and hosted by AnVIL." + list_table_class = tables.DCCProcessedDataWorkspaceTable + workspace_data_model = models.DCCProcessedDataWorkspace + workspace_data_form_class = forms.DCCProcessedDataWorkspaceForm + workspace_detail_template_name = ( + "gregor_anvil/dccprocesseddataworkspace_detail.html" + ) diff --git a/gregor_django/gregor_anvil/admin.py b/gregor_django/gregor_anvil/admin.py index a6ceb773..cc46b622 100644 --- a/gregor_django/gregor_anvil/admin.py +++ b/gregor_django/gregor_anvil/admin.py @@ -59,6 +59,22 @@ class PartnerGroupAdmin(SimpleHistoryAdmin): ) +@admin.register(models.UploadCycle) +class UploadCycleAdmin(SimpleHistoryAdmin): + """Admin class for the UploadCycle model.""" + + list_display = ( + "cycle", + "start_date", + "end_date", + ) + sortable_by = ( + "cycle", + "start_date", + "end_date", + ) + + @admin.register(models.UploadWorkspace) class UploadWorkspaceAdmin(SimpleHistoryAdmin): """Admin class for the UploadWorkspace model.""" @@ -73,6 +89,7 @@ class UploadWorkspaceAdmin(SimpleHistoryAdmin): list_filter = ( "research_center", "consent_group", + "upload_cycle", ) sortable_by = ( "id", @@ -81,17 +98,68 @@ class UploadWorkspaceAdmin(SimpleHistoryAdmin): ) -@admin.register(models.UploadCycle) -class UploadCycleAdmin(SimpleHistoryAdmin): - """Admin class for the UploadCycle model.""" +@admin.register(models.ExampleWorkspace) +class ExampleWorkspaceAdmin(SimpleHistoryAdmin): + """Admin class for the ExampleWorkspace model.""" list_display = ( - "cycle", - "start_date", - "end_date", + "id", + "workspace", ) sortable_by = ( - "cycle", - "start_date", - "end_date", + "id", + "workspace", + ) + + +@admin.register(models.TemplateWorkspace) +class TemplateWorkspaceAdmin(SimpleHistoryAdmin): + """Admin class for the TemplateWorkspace model.""" + + list_display = ( + "id", + "workspace", + ) + sortable_by = ( + "id", + "workspace", + ) + + +@admin.register(models.CombinedConsortiumDataWorkspace) +class CombinedConsortiumDataWorkspaceAdmin(SimpleHistoryAdmin): + """Admin class for the CombinedConsortiumDataWorkspace model.""" + + list_display = ( + "id", + "workspace", + "upload_cycle", + ) + list_filter = ("upload_cycle",) + sortable_by = ( + "id", + "workspace", + "upload_cycle", + ) + + +@admin.register(models.ReleaseWorkspace) +class ReleaseWorkspaceAdmin(SimpleHistoryAdmin): + """Admin class for the ReleaseWorkspace model.""" + + list_display = ( + "id", + "workspace", + "upload_cycle", + "consent_group", + ) + list_filter = ( + "upload_cycle", + "consent_group", + ) + sortable_by = ( + "id", + "workspace", + "upload_cycle", + "consent_group", ) diff --git a/gregor_django/gregor_anvil/forms.py b/gregor_django/gregor_anvil/forms.py index 067cfbe2..7cd723a7 100644 --- a/gregor_django/gregor_anvil/forms.py +++ b/gregor_django/gregor_anvil/forms.py @@ -193,3 +193,33 @@ def clean(self): # that all upload_workspaces have the same upload_cycle. if upload_cycle != upload_workspaces[0].upload_cycle: raise ValidationError(self.ERROR_UPLOAD_CYCLE) + + return cleaned_data + + +class DCCProcessingWorkspaceForm(Bootstrap5MediaFormMixin, forms.ModelForm): + """Form for a DCCProcessingWorkspace object.""" + + ERROR_UPLOAD_CYCLE = ( + "upload_cycle must match upload_cycle of all upload_workspaces." + ) + + class Meta: + model = models.DCCProcessingWorkspace + fields = ( + "upload_cycle", + "purpose", + "workspace", + ) + + +class DCCProcessedDataWorkspaceForm(Bootstrap5MediaFormMixin, forms.ModelForm): + """Form for a DCCProcessedDataWorkspace object.""" + + class Meta: + model = models.DCCProcessedDataWorkspace + fields = ( + "upload_cycle", + "consent_group", + "workspace", + ) diff --git a/gregor_django/gregor_anvil/migrations/0014_alter_uploadcycle_options.py b/gregor_django/gregor_anvil/migrations/0014_alter_uploadcycle_options.py new file mode 100644 index 00000000..7f124e0a --- /dev/null +++ b/gregor_django/gregor_anvil/migrations/0014_alter_uploadcycle_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.19 on 2023-06-20 23:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gregor_anvil', '0013_remove_version_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='uploadcycle', + options={'ordering': ['cycle']}, + ), + ] diff --git a/gregor_django/gregor_anvil/migrations/0015_dccworkspaces.py b/gregor_django/gregor_anvil/migrations/0015_dccworkspaces.py new file mode 100644 index 00000000..a0d10ac1 --- /dev/null +++ b/gregor_django/gregor_anvil/migrations/0015_dccworkspaces.py @@ -0,0 +1,95 @@ +# Generated by Django 3.2.19 on 2023-07-10 22:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anvil_consortium_manager', '0012_managedgroup_email_unique'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gregor_anvil', '0014_alter_uploadcycle_options'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalDCCProcessingWorkspace', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('purpose', models.TextField(help_text='The type of processing that is done in this workspace.')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('upload_cycle', models.ForeignKey(blank=True, db_constraint=False, help_text='Upload cycle associated with this workspace.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='gregor_anvil.uploadcycle')), + ('workspace', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.workspace')), + ], + options={ + 'verbose_name': 'historical dcc processing workspace', + 'verbose_name_plural': 'historical dcc processing workspaces', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalDCCProcessedDataWorkspace', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('consent_group', models.ForeignKey(blank=True, db_constraint=False, help_text='Consent group associated with this data.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='gregor_anvil.consentgroup')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('upload_cycle', models.ForeignKey(blank=True, db_constraint=False, help_text='Upload cycle associated with this workspace.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='gregor_anvil.uploadcycle')), + ('workspace', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.workspace')), + ], + options={ + 'verbose_name': 'historical dcc processed data workspace', + 'verbose_name_plural': 'historical dcc processed data workspaces', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='DCCProcessingWorkspace', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('purpose', models.TextField(help_text='The type of processing that is done in this workspace.')), + ('upload_cycle', models.ForeignKey(help_text='Upload cycle associated with this workspace.', on_delete=django.db.models.deletion.PROTECT, to='gregor_anvil.uploadcycle')), + ('workspace', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.workspace')), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DCCProcessedDataWorkspace', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('consent_group', models.ForeignKey(help_text='Consent group associated with this data.', on_delete=django.db.models.deletion.PROTECT, to='gregor_anvil.consentgroup')), + ('upload_cycle', models.ForeignKey(help_text='Upload cycle associated with this workspace.', on_delete=django.db.models.deletion.PROTECT, to='gregor_anvil.uploadcycle')), + ('workspace', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.workspace')), + ], + ), + migrations.AddConstraint( + model_name='dccprocesseddataworkspace', + constraint=models.UniqueConstraint(fields=('upload_cycle', 'consent_group'), name='unique_dcc_processed_data_workspace'), + ), + ] diff --git a/gregor_django/gregor_anvil/models.py b/gregor_django/gregor_anvil/models.py index a541fd58..4d0d6a70 100644 --- a/gregor_django/gregor_anvil/models.py +++ b/gregor_django/gregor_anvil/models.py @@ -95,6 +95,11 @@ class UploadCycle(TimeStampedModel, models.Model): # Django simple history. history = HistoricalRecords() + class Meta: + ordering = [ + "cycle", + ] + def __str__(self): return "U{cycle:02d}".format(cycle=self.cycle) @@ -199,3 +204,39 @@ def get_dbgap_accession(self): return "phs{phs:06d}.v{v}.p{p}".format( phs=self.phs, v=self.dbgap_version, p=self.dbgap_participant_set ) + + +class DCCProcessingWorkspace(TimeStampedModel, BaseWorkspaceData): + + upload_cycle = models.ForeignKey( + UploadCycle, + on_delete=models.PROTECT, + help_text="Upload cycle associated with this workspace.", + ) + purpose = models.TextField( + help_text="The type of processing that is done in this workspace." + ) + + +class DCCProcessedDataWorkspace(TimeStampedModel, BaseWorkspaceData): + """A workspace to store DCC processed data, split by consent (e.g., re-aligned CRAMs and gVCFs).""" + + upload_cycle = models.ForeignKey( + UploadCycle, + on_delete=models.PROTECT, + help_text="Upload cycle associated with this workspace.", + ) + consent_group = models.ForeignKey( + ConsentGroup, + help_text="Consent group associated with this data.", + on_delete=models.PROTECT, + ) + + class Meta: + constraints = [ + # Model uniqueness. + models.UniqueConstraint( + name="unique_dcc_processed_data_workspace", + fields=["upload_cycle", "consent_group"], + ), + ] diff --git a/gregor_django/gregor_anvil/tables.py b/gregor_django/gregor_anvil/tables.py index 184b2fcc..eea7c181 100644 --- a/gregor_django/gregor_anvil/tables.py +++ b/gregor_django/gregor_anvil/tables.py @@ -226,3 +226,33 @@ class Meta: def render_number_workspaces(self, record): return record.releaseworkspace.upload_workspaces.count() + + +class DCCProcessingWorkspaceTable(tables.Table): + """A table for Workspaces that includes fields from DCCProcessingWorkspace.""" + + name = tables.columns.Column(linkify=True) + + class Meta: + model = Workspace + fields = ( + "name", + "dccprocessingworkspace__upload_cycle", + "dccprocessingworkspace__purpose", + ) + + +class DCCProcessedDataWorkspaceTable(WorkspaceSharedWithConsortiumTable, tables.Table): + """A table for Workspaces that includes fields from DCCProcessedDataWorkspace.""" + + name = tables.columns.Column(linkify=True) + dccprocesseddataworkspace__consent_group = tables.columns.Column(linkify=True) + + class Meta: + model = Workspace + fields = ( + "name", + "dccprocesseddataworkspace__upload_cycle", + "dccprocesseddataworkspace__consent_group", + "is_shared", + ) diff --git a/gregor_django/gregor_anvil/tests/factories.py b/gregor_django/gregor_anvil/tests/factories.py index 90415dff..c98ace24 100644 --- a/gregor_django/gregor_anvil/tests/factories.py +++ b/gregor_django/gregor_anvil/tests/factories.py @@ -123,3 +123,31 @@ class Meta: WorkspaceFactory, workspace_type="release", ) + + +class DCCProcessingWorkspaceFactory(DjangoModelFactory): + """A factory for the class DCCProcessingWorkspace model.""" + + class Meta: + model = models.DCCProcessingWorkspace + + upload_cycle = SubFactory(UploadCycleFactory) + purpose = Faker("paragraph") + workspace = SubFactory( + WorkspaceFactory, + workspace_type="dcc_processing", + ) + + +class DCCProcessedDataWorkspaceFactory(DjangoModelFactory): + """A factory for the class DCCProcessedDataWorkspace model.""" + + class Meta: + model = models.DCCProcessedDataWorkspace + + consent_group = SubFactory(ConsentGroupFactory) + upload_cycle = SubFactory(UploadCycleFactory) + workspace = SubFactory( + WorkspaceFactory, + workspace_type="dcc_processed_data", + ) diff --git a/gregor_django/gregor_anvil/tests/test_forms.py b/gregor_django/gregor_anvil/tests/test_forms.py index a1a5c821..adc6909b 100644 --- a/gregor_django/gregor_anvil/tests/test_forms.py +++ b/gregor_django/gregor_anvil/tests/test_forms.py @@ -666,3 +666,132 @@ def test_clean(self): self.form_class.ERROR_UPLOAD_CYCLE, form.errors[NON_FIELD_ERRORS][0], ) + + +class DCCProcessingWorkspaceFormTest(TestCase): + """Tests for the DCCProcessingWorkspace class.""" + + form_class = forms.DCCProcessingWorkspaceForm + + def setUp(self): + """Create a workspace for use in the form.""" + self.workspace = WorkspaceFactory() + self.upload_cycle = factories.UploadCycleFactory.create() + + def test_valid(self): + """Form is valid with necessary input.""" + upload_workspace = factories.UploadWorkspaceFactory.create() + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "upload_cycle": upload_workspace.upload_cycle, + "purpose": "foo", + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_missing_workspace(self): + """Form is invalid when missing workspace.""" + form_data = { + # "workspace": self.workspace, + "upload_cycle": self.upload_cycle, + "purpose": "foo", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("workspace", form.errors) + self.assertEqual(len(form.errors["workspace"]), 1) + self.assertIn("required", form.errors["workspace"][0]) + + def test_invalid_missing_upload_cycle(self): + """Form is invalid when missing upload_cycle.""" + form_data = { + "workspace": self.workspace, + # "upload_cycle": self.upload_cycle, + "purpose": "foo", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("upload_cycle", form.errors) + self.assertEqual(len(form.errors["upload_cycle"]), 1) + self.assertIn("required", form.errors["upload_cycle"][0]) + + def test_invalid_missing_purpose(self): + """Form is invalid when missing purpose.""" + form_data = { + "workspace": self.workspace, + "upload_cycle": self.upload_cycle, + # "purpose": "foo", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("purpose", form.errors) + self.assertEqual(len(form.errors["purpose"]), 1) + self.assertIn("required", form.errors["purpose"][0]) + + def test_invalid_blank_purpose(self): + """Form is invalid when purpose is blank.""" + form_data = { + "workspace": self.workspace, + "upload_cycle": self.upload_cycle, + "purpose": "", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("purpose", form.errors) + self.assertEqual(len(form.errors["purpose"]), 1) + self.assertIn("required", form.errors["purpose"][0]) + + +class DCCProcessedDataWorkspaceFormTest(TestCase): + """Tests for the DCCProcessedDataWorkspaceForm class.""" + + form_class = forms.DCCProcessedDataWorkspaceForm + + def setUp(self): + """Create a workspace for use in the form.""" + self.workspace = WorkspaceFactory() + self.upload_cycle = factories.UploadCycleFactory.create() + self.consent_group = factories.ConsentGroupFactory() + + def test_valid(self): + """Form is valid with necessary input.""" + form_data = { + "upload_cycle": self.upload_cycle, + "consent_group": self.consent_group, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_missing_upload_cycle(self): + """Form is invalid when missing research_center.""" + form_data = { + # "upload_cycle": self.upload_cycle, + "consent_group": self.consent_group, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("upload_cycle", form.errors) + self.assertEqual(len(form.errors["upload_cycle"]), 1) + self.assertIn("required", form.errors["upload_cycle"][0]) + + def test_invalid_missing_consent_group(self): + """Form is invalid when missing consent_group.""" + form_data = { + "upload_cycle": self.upload_cycle, + # "consent_group": self.consent_group, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("consent_group", form.errors) + self.assertEqual(len(form.errors["consent_group"]), 1) + self.assertIn("required", form.errors["consent_group"][0]) diff --git a/gregor_django/gregor_anvil/tests/test_models.py b/gregor_django/gregor_anvil/tests/test_models.py index 37b057a6..787935a1 100644 --- a/gregor_django/gregor_anvil/tests/test_models.py +++ b/gregor_django/gregor_anvil/tests/test_models.py @@ -141,6 +141,14 @@ def test_str_method(self): self.assertIsInstance(instance.__str__(), str) self.assertEqual(instance.__str__(), "U01") + def test_model_order(self): + """Models are ordered by cycle.""" + instance_1 = factories.UploadCycleFactory.create(cycle=2) + instance_2 = factories.UploadCycleFactory.create(cycle=1) + qs = models.UploadCycle.objects.all() + self.assertEqual(qs[0], instance_2) + self.assertEqual(qs[1], instance_1) + def test_get_absolute_url(self): """The get_absolute_url() method works.""" instance = factories.UploadCycleFactory() @@ -560,3 +568,81 @@ def test_get_dbgap_accession(self): dbgap_version=1, dbgap_participant_set=2 ) self.assertEqual(instance.get_dbgap_accession(), "phs003047.v1.p2") + + +class DCCProcessingWorkspaceTest(TestCase): + """Tests for the DCCProcessingWorkspace model.""" + + def test_model_saving(self): + """Creation using the model constructor and .save() works.""" + workspace = WorkspaceFactory.create() + upload_cycle = factories.UploadCycleFactory.create() + instance = models.DCCProcessingWorkspace( + upload_cycle=upload_cycle, + purpose="foo", + workspace=workspace, + ) + instance.save() + self.assertIsInstance(instance, models.DCCProcessingWorkspace) + + def test_str_method(self): + """The custom __str__ method returns the correct string.""" + instance = factories.DCCProcessingWorkspaceFactory.create() + instance.save() + self.assertIsInstance(instance.__str__(), str) + self.assertEqual(instance.__str__(), instance.workspace.__str__()) + + def test_two_workspaces_same_upload_cycle(self): + """Can have two workspaces with the same upload cycle.""" + dcc_processing_workspace = factories.DCCProcessingWorkspaceFactory.create() + workspace = WorkspaceFactory.create() + instance = factories.DCCProcessingWorkspaceFactory.build( + upload_cycle=dcc_processing_workspace.upload_cycle, + workspace=workspace, + ) + instance.full_clean() + instance.save() + self.assertEqual(models.DCCProcessingWorkspace.objects.count(), 2) + + +class DCCProcessedDataWorkspaceTest(TestCase): + """Tests for the DCCProcessedDataWorkspace model.""" + + def test_model_saving(self): + """Creation using the model constructor and .save() works.""" + upload_cycle = factories.UploadCycleFactory.create() + consent_group = factories.ConsentGroupFactory.create() + workspace = WorkspaceFactory.create() + instance = models.DCCProcessedDataWorkspace( + consent_group=consent_group, + upload_cycle=upload_cycle, + workspace=workspace, + ) + instance.save() + self.assertIsInstance(instance, models.DCCProcessedDataWorkspace) + + def test_str_method(self): + """The custom __str__ method returns the correct string.""" + instance = factories.DCCProcessedDataWorkspaceFactory.create() + instance.save() + self.assertIsInstance(instance.__str__(), str) + self.assertEqual(instance.__str__(), instance.workspace.__str__()) + + def test_unique(self): + """Cannot have two workspaces with the same upload cycle and consent group.""" + dcc_processed_data_workspace = ( + factories.DCCProcessedDataWorkspaceFactory.create() + ) + workspace = WorkspaceFactory.create() + instance = factories.DCCProcessedDataWorkspaceFactory.build( + upload_cycle=dcc_processed_data_workspace.upload_cycle, + consent_group=dcc_processed_data_workspace.consent_group, + workspace=workspace, + ) + with self.assertRaises(ValidationError) as e: + instance.full_clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn(NON_FIELD_ERRORS, e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict[NON_FIELD_ERRORS]), 1) + with self.assertRaises(IntegrityError): + instance.save() diff --git a/gregor_django/gregor_anvil/tests/test_tables.py b/gregor_django/gregor_anvil/tests/test_tables.py index ad1ee793..c364f64b 100644 --- a/gregor_django/gregor_anvil/tests/test_tables.py +++ b/gregor_django/gregor_anvil/tests/test_tables.py @@ -264,3 +264,57 @@ def test_row_count_with_two_workspace_types(self): factories.ExampleWorkspaceFactory.create_batch(2) table = self.table_class(self.get_qs()) self.assertEqual(len(table.rows), 2) + + +class DCCProcessingWorkspaceTableTest(TestCase): + model = Workspace + model_factory = factories.DCCProcessingWorkspaceFactory + table_class = tables.DCCProcessingWorkspaceTable + + def test_row_count_with_no_objects(self): + table = self.table_class( + self.model.objects.filter(workspace_type="dcc_processing") + ) + self.assertEqual(len(table.rows), 0) + + def test_row_count_with_one_object(self): + self.model_factory.create() + table = self.table_class( + self.model.objects.filter(workspace_type="dcc_processing") + ) + self.assertEqual(len(table.rows), 1) + + def test_row_count_with_two_objects(self): + # These values are coded into the model, so need to create separately. + self.model_factory.create_batch(2) + table = self.table_class( + self.model.objects.filter(workspace_type="dcc_processing") + ) + self.assertEqual(len(table.rows), 2) + + +class DCCProcessedDataWorkspaceTableTest(TestCase): + model = Workspace + model_factory = factories.DCCProcessedDataWorkspaceFactory + table_class = tables.DCCProcessedDataWorkspaceTable + + def test_row_count_with_no_objects(self): + table = self.table_class( + self.model.objects.filter(workspace_type="dcc_processed_data") + ) + self.assertEqual(len(table.rows), 0) + + def test_row_count_with_one_object(self): + self.model_factory.create() + table = self.table_class( + self.model.objects.filter(workspace_type="dcc_processed_data") + ) + self.assertEqual(len(table.rows), 1) + + def test_row_count_with_two_objects(self): + # These values are coded into the model, so need to create separately. + self.model_factory.create_batch(2) + table = self.table_class( + self.model.objects.filter(workspace_type="dcc_processed_data") + ) + self.assertEqual(len(table.rows), 2) diff --git a/gregor_django/gregor_anvil/tests/test_views.py b/gregor_django/gregor_anvil/tests/test_views.py index 4a811b5f..47224bd9 100644 --- a/gregor_django/gregor_anvil/tests/test_views.py +++ b/gregor_django/gregor_anvil/tests/test_views.py @@ -785,7 +785,7 @@ def test_table_classes(self): self.client.force_login(self.user) response = self.client.get(self.get_url(obj.cycle)) self.assertIn("tables", response.context_data) - self.assertEqual(len(response.context_data["tables"]), 3) + self.assertEqual(len(response.context_data["tables"]), 5) self.assertIsInstance( response.context_data["tables"][0], tables.UploadWorkspaceTable ) @@ -796,6 +796,12 @@ def test_table_classes(self): self.assertIsInstance( response.context_data["tables"][2], tables.ReleaseWorkspaceTable ) + self.assertIsInstance( + response.context_data["tables"][3], tables.DCCProcessingWorkspaceTable + ) + self.assertIsInstance( + response.context_data["tables"][4], tables.DCCProcessedDataWorkspaceTable + ) def test_upload_workspace_table(self): """Contains a table of UploadWorkspaces from this upload cycle.""" @@ -835,6 +841,30 @@ def test_release_workspace_table(self): self.assertIn(workspace.workspace, table.data) self.assertNotIn(other_workspace.workspace, table.data) + def test_dcc_processing_workspace_table(self): + """Contains a table of DCCProcessingWorkspaces from this upload cycle.""" + obj = self.model_factory.create() + workspace = factories.DCCProcessingWorkspaceFactory.create(upload_cycle=obj) + other_workspace = factories.DCCProcessingWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(self.get_url(obj.cycle)) + table = response.context_data["tables"][3] + self.assertEqual(len(table.rows), 1) + self.assertIn(workspace.workspace, table.data) + self.assertNotIn(other_workspace.workspace, table.data) + + def test_dcc_processed_data_workspace_table(self): + """Contains a table of DCCProcessedDataWorkspaces from this upload cycle.""" + obj = self.model_factory.create() + workspace = factories.DCCProcessedDataWorkspaceFactory.create(upload_cycle=obj) + other_workspace = factories.DCCProcessedDataWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(self.get_url(obj.cycle)) + table = response.context_data["tables"][4] + self.assertEqual(len(table.rows), 1) + self.assertIn(workspace.workspace, table.data) + self.assertNotIn(other_workspace.workspace, table.data) + class UploadCycleListTest(TestCase): """Tests for the UploadCycleList view.""" @@ -1687,3 +1717,63 @@ def test_correct_count_consortium_members_with_access_to_workspaces_in_context( response = self.client.get(self.get_url()) self.assertTrue("verified_linked_accounts" in response.context_data) self.assertEqual(response.context_data["verified_linked_accounts"], 1) + + +class DCCProcessingWorkspaceDetailTest(TestCase): + """Tests of the anvil_consortium_manager WorkspaceDetail view using the DCCProcessingWorkspace adapter.""" + + def setUp(self): + """Set up test class.""" + self.factory = RequestFactory() + # Create a user with both view and edit permission. + self.user = User.objects.create_user(username="test", password="test") + self.user.user_permissions.add( + Permission.objects.get( + codename=acm_models.AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + self.object = factories.DCCProcessingWorkspaceFactory.create() + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("anvil_consortium_manager:workspaces:detail", args=args) + + def test_status_code(self): + """Response has a status code of 200.""" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.object.workspace.billing_project.name, self.object.workspace.name + ) + ) + self.assertEqual(response.status_code, 200) + + +class DCCProcessedDataWorkspaceDetailTest(TestCase): + """Tests of the anvil_consortium_manager WorkspaceDetail view using the DCCProcessedDataWorkspace adapter.""" + + def setUp(self): + """Set up test class.""" + self.factory = RequestFactory() + # Create a user with both view and edit permission. + self.user = User.objects.create_user(username="test", password="test") + self.user.user_permissions.add( + Permission.objects.get( + codename=acm_models.AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + self.object = factories.DCCProcessedDataWorkspaceFactory.create() + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("anvil_consortium_manager:workspaces:detail", args=args) + + def test_status_code(self): + """Response has a status code of 200.""" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.object.workspace.billing_project.name, self.object.workspace.name + ) + ) + self.assertEqual(response.status_code, 200) diff --git a/gregor_django/gregor_anvil/urls.py b/gregor_django/gregor_anvil/urls.py index 7c08203e..771957d9 100644 --- a/gregor_django/gregor_anvil/urls.py +++ b/gregor_django/gregor_anvil/urls.py @@ -44,6 +44,7 @@ ], "reports", ) + urlpatterns = [ # path("", views.Index.as_view(), name="index"), path("research_centers/", include(research_center_patterns)), diff --git a/gregor_django/gregor_anvil/views.py b/gregor_django/gregor_anvil/views.py index 89faff1b..3f5ba059 100644 --- a/gregor_django/gregor_anvil/views.py +++ b/gregor_django/gregor_anvil/views.py @@ -88,6 +88,8 @@ class UploadCycleDetail( tables.UploadWorkspaceTable, tables.CombinedConsortiumDataWorkspaceTable, tables.ReleaseWorkspaceTable, + tables.DCCProcessingWorkspaceTable, + tables.DCCProcessedDataWorkspaceTable, ] def get_tables_data(self): @@ -100,7 +102,19 @@ def get_tables_data(self): release_workspace_qs = Workspace.objects.filter( releaseworkspace__upload_cycle=self.object ) - return [upload_workspace_qs, combined_workspace_qs, release_workspace_qs] + dcc_processing_workspace_qs = Workspace.objects.filter( + dccprocessingworkspace__upload_cycle=self.object, + ) + dcc_processed_data_workspace_qs = Workspace.objects.filter( + dccprocesseddataworkspace__upload_cycle=self.object, + ) + return [ + upload_workspace_qs, + combined_workspace_qs, + release_workspace_qs, + dcc_processing_workspace_qs, + dcc_processed_data_workspace_qs, + ] class UploadCycleList(AnVILConsortiumManagerViewRequired, SingleTableView): diff --git a/gregor_django/templates/anvil_consortium_manager/navbar.html b/gregor_django/templates/anvil_consortium_manager/navbar.html index 34aac7a5..e1671650 100644 --- a/gregor_django/templates/anvil_consortium_manager/navbar.html +++ b/gregor_django/templates/anvil_consortium_manager/navbar.html @@ -28,6 +28,10 @@
  • Workspace report
  • +
  • +
  • + Look up a user +
  • diff --git a/gregor_django/templates/gregor_anvil/dccprocesseddataworkspace_detail.html b/gregor_django/templates/gregor_anvil/dccprocesseddataworkspace_detail.html new file mode 100644 index 00000000..38206481 --- /dev/null +++ b/gregor_django/templates/gregor_anvil/dccprocesseddataworkspace_detail.html @@ -0,0 +1,17 @@ +{% extends "anvil_consortium_manager/workspace_detail.html" %} + +{% block workspace_data %} +
    +
    + +
    Upload cycle
    +
    + {{ object.dccprocesseddataworkspace.upload_cycle }}
    +
    + +
    Consent group
    +
    + {{ object.dccprocesseddataworkspace.consent_group }} +
    +
    +{% endblock workspace_data %} diff --git a/gregor_django/templates/gregor_anvil/dccprocessingworkspace_detail.html b/gregor_django/templates/gregor_anvil/dccprocessingworkspace_detail.html new file mode 100644 index 00000000..976abd28 --- /dev/null +++ b/gregor_django/templates/gregor_anvil/dccprocessingworkspace_detail.html @@ -0,0 +1,13 @@ +{% extends "anvil_consortium_manager/workspace_detail.html" %} + +{% block workspace_data %} +
    +
    +
    Upload cycle
    + {{ object.dccprocessingworkspace.upload_cycle }}
    +
    +
    Purpose
    + {{ object.dccprocessingworkspace.purpose }} +
    +
    +{% endblock workspace_data %} diff --git a/gregor_django/templates/gregor_anvil/uploadcycle_detail.html b/gregor_django/templates/gregor_anvil/uploadcycle_detail.html index 9f251f1b..91a89d2a 100644 --- a/gregor_django/templates/gregor_anvil/uploadcycle_detail.html +++ b/gregor_django/templates/gregor_anvil/uploadcycle_detail.html @@ -35,4 +35,14 @@

    Release workspaces

    {% render_table tables.2 %} +

    DCC processing workspaces

    +
    + {% render_table tables.3 %} +
    + +

    DCC processed data workspaces

    +
    + {% render_table tables.4 %} +
    + {% endblock after_panel %} diff --git a/gregor_django/templates/users/userlookup_form.html b/gregor_django/templates/users/userlookup_form.html new file mode 100644 index 00000000..e586cb06 --- /dev/null +++ b/gregor_django/templates/users/userlookup_form.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load static %} +{% load crispy_forms_tags %} + +{% block title %}Look up a user{% endblock title %} + +{% block content %} + +

    User lookup

    + +
    + {% csrf_token %} + {{ form|crispy }} + +
    +{% endblock %} + +{% block inline_javascript %} + {{ form.media }} +{% endblock inline_javascript %} diff --git a/gregor_django/users/forms.py b/gregor_django/users/forms.py index 80cd97ae..c5e6de5e 100644 --- a/gregor_django/users/forms.py +++ b/gregor_django/users/forms.py @@ -1,3 +1,6 @@ +from anvil_consortium_manager.forms import Bootstrap5MediaFormMixin +from dal import autocomplete +from django import forms from django.contrib.auth import forms as admin_forms from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ @@ -17,3 +20,17 @@ class Meta(admin_forms.UserCreationForm.Meta): error_messages = { "username": {"unique": _("This username has already been taken.")} } + + +class UserLookupForm(Bootstrap5MediaFormMixin, forms.Form): + """Form for the user lookup.""" + + user = forms.ModelChoiceField( + queryset=User.objects.all(), + widget=autocomplete.ModelSelect2( + url="users:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), + required=True, + help_text="Enter either the name or username to search.", + ) diff --git a/gregor_django/users/tests/test_forms.py b/gregor_django/users/tests/test_forms.py index 2b7989a6..15fd3d63 100644 --- a/gregor_django/users/tests/test_forms.py +++ b/gregor_django/users/tests/test_forms.py @@ -2,10 +2,12 @@ Module for all Form Tests. """ import pytest +from django.test import TestCase from django.utils.translation import gettext_lazy as _ -from gregor_django.users.forms import UserCreationForm +from gregor_django.users.forms import UserCreationForm, UserLookupForm from gregor_django.users.models import User +from gregor_django.users.tests.factories import UserFactory pytestmark = pytest.mark.django_db @@ -37,3 +39,27 @@ def test_username_validation_error_msg(self, user: User): assert len(form.errors) == 1 assert "username" in form.errors assert form.errors["username"][0] == _("This username has already been taken.") + + +class UserLookupFormTest(TestCase): + + form_class = UserLookupForm + + def test_valid(self): + """Form is valid with necessary input.""" + user_obj = UserFactory.create() + form_data = { + "user": user_obj, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_missing_name(self): + """Form is invalid when missing name.""" + form_data = {} + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("user", form.errors) + self.assertEqual(len(form.errors["user"]), 1) + self.assertIn("required", form.errors["user"][0]) diff --git a/gregor_django/users/tests/test_views.py b/gregor_django/users/tests/test_views.py index 3b966390..55b00bb8 100644 --- a/gregor_django/users/tests/test_views.py +++ b/gregor_django/users/tests/test_views.py @@ -1,3 +1,5 @@ +import json + import pytest from django.conf import settings from django.contrib import messages @@ -6,10 +8,12 @@ from django.contrib.messages.middleware import MessageMiddleware from django.contrib.sessions.middleware import SessionMiddleware from django.http import HttpRequest -from django.test import RequestFactory +from django.shortcuts import resolve_url +from django.test import RequestFactory, TestCase from django.urls import reverse -from gregor_django.users.forms import UserChangeForm +from gregor_django.users.forms import UserChangeForm, UserLookupForm +from gregor_django.users.tests.factories import UserFactory from gregor_django.users.views import UserRedirectView, UserUpdateView, user_detail_view pytestmark = pytest.mark.django_db(transaction=True) @@ -104,3 +108,190 @@ def test_not_authenticated(self, user: User, rf: RequestFactory): assert response.status_code == 302 assert response.url == f"{login_url}?next=/fake-url/" + + +class UserAutocompleteViewTest(TestCase): + def setUp(self): + """Set up test class.""" + self.factory = RequestFactory() + # Create a user with the correct permissions. + self.user = User.objects.create_user( + username="test", password="test", email="test@example.com" + ) + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("users:autocomplete", args=args) + + def test_view_redirect_not_logged_in(self): + "View redirects to login view when user is not logged in." + # Need a client for redirects. + response = self.client.get(self.get_url()) + self.assertRedirects( + response, resolve_url(settings.LOGIN_URL) + "?next=" + self.get_url() + ) + + def test_status_code_logged_in(self): + """Returns successful response code.""" + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertEqual(response.status_code, 200) + + def test_returns_all_objects(self): + """Returns all objects when there is no query.""" + UserFactory.create_batch(9) + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + returned_ids = [ + int(x["id"]) + for x in json.loads(response.content.decode("utf-8"))["results"] + ] + self.assertEqual(len(returned_ids), User.objects.count()) + self.assertEqual( + sorted(returned_ids), + sorted(User.objects.values_list("id", flat=True)), + ) + + def test_returns_correct_object_match(self): + """Returns the correct objects when query matches the name.""" + object = UserFactory.create( + username="another-user", + password="another-passwd", + email="another-user@example.com", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"q": "another-user"}) + returned_ids = [ + int(x["id"]) + for x in json.loads(response.content.decode("utf-8"))["results"] + ] + + self.assertEqual(len(returned_ids), 1) + self.assertEqual(returned_ids[0], object.pk) + + def test_returns_correct_object_starting_with_query(self): + """Returns the correct objects when query matches the beginning of the name.""" + object = UserFactory.create( + username="another-user", + password="another-passwd", + email="another-user@example.com", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"q": "another"}) + returned_ids = [ + int(x["id"]) + for x in json.loads(response.content.decode("utf-8"))["results"] + ] + self.assertEqual(len(returned_ids), 1) + self.assertEqual(returned_ids[0], object.pk) + + def test_returns_correct_object_containing_query(self): + """Returns the correct objects when the name contains the query.""" + object = UserFactory.create( + username="another-user", + password="another-passwd", + email="another-user@example.com", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"q": "use"}) + returned_ids = [ + int(x["id"]) + for x in json.loads(response.content.decode("utf-8"))["results"] + ] + self.assertEqual(len(returned_ids), 1) + self.assertEqual(returned_ids[0], object.pk) + + def test_returns_correct_object_case_insensitive(self): + """Returns the correct objects when query matches the beginning of the name.""" + object = UserFactory.create( + username="another-user", + password="another-passwd", + email="another-user@example.com", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(), {"q": "ANOTHER-USER"}) + returned_ids = [ + int(x["id"]) + for x in json.loads(response.content.decode("utf-8"))["results"] + ] + self.assertEqual(len(returned_ids), 1) + self.assertEqual(returned_ids[0], object.pk) + + +class UserLookupTest(TestCase): + """Test for UserLookup view""" + + def setUp(self): + """Set up test class.""" + self.factory = RequestFactory() + self.model_factory = UserFactory + # Create a user. + self.user = User.objects.create_user(username="test", password="test") + + def get_url(self): + """Get the url for the view being tested.""" + return reverse("users:lookup") + + def test_view_redirect_not_logged_in(self): + "View redirects to login view when user is not logged in." + response = self.client.get(self.get_url()) + self.assertRedirects( + response, resolve_url(settings.LOGIN_URL) + "?next=" + self.get_url() + ) + + def test_status_code_with_user_permission(self): + """Returns successful response code.""" + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertEqual(response.status_code, 200) + + def test_form_class(self): + """The form class is as expected.""" + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("form", response.context_data) + self.assertIsInstance(response.context_data["form"], UserLookupForm) + + def test_redirect_to_the_correct_profile_page(self): + """The search view correctly redirect to the user profile page""" + object = UserFactory.create( + username="user1", + password="passwd", + email="user1@example.com", + ) + self.client.force_login(self.user) + response = self.client.post(self.get_url(), {"user": object.pk}) + self.assertRedirects( + response, + resolve_url(reverse("users:detail", kwargs={"username": object.username})), + ) + + def test_invalid_input(self): + """Posting invalid data re-renders the form with an error.""" + self.client.force_login(self.user) + response = self.client.post( + self.get_url(), + {"user": -1}, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors.keys()), 1) + self.assertIn("user", form.errors.keys()) + self.assertEqual(len(form.errors["user"]), 1) + self.assertIn("valid choice", form.errors["user"][0]) + + def test_blank_user(self): + """Posting invalid data does not create an object.""" + self.client.force_login(self.user) + response = self.client.post( + self.get_url(), + {}, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors.keys()), 1) + self.assertIn("user", form.errors.keys()) + self.assertEqual(len(form.errors["user"]), 1) + self.assertIn("required", form.errors["user"][0]) diff --git a/gregor_django/users/urls.py b/gregor_django/users/urls.py index 900586d6..2b22f825 100644 --- a/gregor_django/users/urls.py +++ b/gregor_django/users/urls.py @@ -1,13 +1,17 @@ from django.urls import path from gregor_django.users.views import ( + user_autocomplete_view, user_detail_view, + user_lookup_view, user_redirect_view, user_update_view, ) app_name = "users" urlpatterns = [ + path("lookup/", view=user_lookup_view, name="lookup"), + path("autocomplete/", view=user_autocomplete_view, name="autocomplete"), path("~redirect/", view=user_redirect_view, name="redirect"), path("~update/", view=user_update_view, name="update"), path("/", view=user_detail_view, name="detail"), diff --git a/gregor_django/users/views.py b/gregor_django/users/views.py index c7b846c0..0d18645c 100644 --- a/gregor_django/users/views.py +++ b/gregor_django/users/views.py @@ -1,9 +1,13 @@ +from dal import autocomplete from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, RedirectView, UpdateView +from django.views.generic import DetailView, FormView, RedirectView, UpdateView + +from .forms import UserLookupForm User = get_user_model() @@ -43,3 +47,37 @@ def get_redirect_url(self): user_redirect_view = UserRedirectView.as_view() + + +class UserAutocompleteView(LoginRequiredMixin, autocomplete.Select2QuerySetView): + """View to provide autocompletion for User.""" + + def get_result_label(self, item): + return "{} ({})".format(item.name, item.username) + + def get_queryset(self): + qs = User.objects.all().order_by("username") + + if self.q: + qs = qs.filter(Q(name__icontains=self.q) | Q(username__icontains=self.q)) + return qs + + +user_autocomplete_view = UserAutocompleteView.as_view() + + +class UserLookup(LoginRequiredMixin, FormView): + template_name = "users/userlookup_form.html" + form_class = UserLookupForm + + def form_valid(self, form): + self.user = form.cleaned_data["user"] + return super().form_valid(form) + + def get_success_url(self): + """Redirect to the user profile page after processing a valid form.""" + + return reverse("users:detail", kwargs={"username": self.user.username}) + + +user_lookup_view = UserLookup.as_view()