diff --git a/docker-compose.yaml b/docker-compose.yaml index 6c2c5de..c881d97 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -35,6 +35,7 @@ services: python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/pathogens.json && python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/signal_types.json && python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/signal_categories.json && + python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/demographic_scopes.json && python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/county.json && python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/hhs.json && python3 /usr/src/signal_documentation/src/manage.py loaddata ./fixtures/hrr.json && diff --git a/src/base/admin.py b/src/base/admin.py index e9e5d33..d850462 100644 --- a/src/base/admin.py +++ b/src/base/admin.py @@ -6,6 +6,7 @@ DescriptedFilter, DescriptedFilterField, Link, + License ) @@ -27,3 +28,12 @@ class LinkAdmin(admin.ModelAdmin): Admin interface for managing link objects. """ list_display: tuple[Literal['url'], Literal['link_type']] = ('url', 'link_type') + + +@admin.register(License) +class GeographyAdmin(admin.ModelAdmin): + """ + Admin interface for managing license objects. + """ + list_display: tuple[Literal['name'], Literal['use_restrictions']] = ('name', 'use_restrictions') + search_fields: tuple[Literal['name']] = ('name',) diff --git a/src/base/migrations/0004_license.py b/src/base/migrations/0004_license.py new file mode 100644 index 0000000..405857e --- /dev/null +++ b/src/base/migrations/0004_license.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.10 on 2024-06-07 12:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0003_descriptedfilter_alter_link_link_type_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='License', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='License', max_length=256, unique=True)), + ('use_restrictions', models.TextField(blank=True, help_text='Use Restrictions', null=True)), + ], + ), + ] diff --git a/src/base/models.py b/src/base/models.py index 96003c7..52c16dc 100644 --- a/src/base/models.py +++ b/src/base/models.py @@ -123,3 +123,20 @@ def get_preview(self) -> LinkPreview: return { 'description': _('No description available'), } + + +class License(models.Model): + """ + A model representing a License. + """ + name: models.CharField = models.CharField(help_text=_('License'), max_length=256, unique=True) + use_restrictions: models.TextField = models.TextField(help_text=_('Use Restrictions'), blank=True, null=True) + + def __str__(self) -> str: + """ + Returns the name of the license as a string. + + :return: The name of the license as a string. + :rtype: str + """ + return self.name diff --git a/src/datasources/migrations/0006_alter_datasource_source_license.py b/src/datasources/migrations/0006_alter_datasource_source_license.py new file mode 100644 index 0000000..7f6f41a --- /dev/null +++ b/src/datasources/migrations/0006_alter_datasource_source_license.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.10 on 2024-06-07 12:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0004_license'), + ('datasources', '0005_sourcesubdivision_external_name'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='source_license', + field=models.ForeignKey(help_text='License', on_delete=django.db.models.deletion.PROTECT, related_name='source_license', to='base.license'), + ), + ] diff --git a/src/datasources/migrations/0007_alter_datasource_source_license.py b/src/datasources/migrations/0007_alter_datasource_source_license.py new file mode 100644 index 0000000..cc5729f --- /dev/null +++ b/src/datasources/migrations/0007_alter_datasource_source_license.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.10 on 2024-06-07 12:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0004_license'), + ('datasources', '0006_alter_datasource_source_license'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='source_license', + field=models.ForeignKey(help_text='License', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='data_sources', to='base.license'), + ), + ] diff --git a/src/datasources/models.py b/src/datasources/models.py index 72a6d4b..95bc15e 100644 --- a/src/datasources/models.py +++ b/src/datasources/models.py @@ -78,10 +78,15 @@ class DataSource(TimeStampedModel): null=True, blank=True ) - source_license: models.CharField = models.CharField( + + source_license: models.ForeignKey = models.ForeignKey( + 'base.License', + related_name='data_sources', help_text=_('License'), - max_length=128 + on_delete=models.PROTECT, + null=True ) + links: models.ManyToManyField = models.ManyToManyField( 'base.Link', help_text=_('DataSource links'), diff --git a/src/datasources/resources.py b/src/datasources/resources.py index b1d3e45..5d64967 100644 --- a/src/datasources/resources.py +++ b/src/datasources/resources.py @@ -5,7 +5,7 @@ from import_export import resources from import_export.fields import Field, widgets -from base.models import Link, LinkTypeChoices +from base.models import Link, LinkTypeChoices, License from datasources.models import DataSource, SourceSubdivision @@ -40,6 +40,7 @@ def before_import_row(self, row, **kwargs) -> None: any additional links specified in 'DUA' or 'Link' columns. """ self.process_links(row) + self.process_licenses(row) self.process_datasource(row) def process_links(self, row) -> None: @@ -58,6 +59,13 @@ def process_links(self, row) -> None: link, created = Link.objects.get_or_create(url=link_url, link_type=link_type) row['Links'] += row['Links'] + f'|{link.url}' + def process_licenses(self, row) -> None: + if row['License']: + license: License + created: bool + license, created = License.objects.get_or_create(name=row['License']) + row['License'] = license + def process_datasource(self, row) -> None: if row['Name']: data_source: DataSource @@ -71,4 +79,6 @@ def process_datasource(self, row) -> None: } ) links: QuerySet[Link] = Link.objects.filter(url__in=row['Links'].split('|')).values_list('id', flat=True) + license: License = License.objects.filter(name=row['License']).first() data_source.links.add(*links) + data_source.source_license = license diff --git a/src/fixtures/demographic_scopes.json b/src/fixtures/demographic_scopes.json new file mode 100644 index 0000000..b3b01b6 --- /dev/null +++ b/src/fixtures/demographic_scopes.json @@ -0,0 +1,47 @@ +[ + { + "model": "signals.DemographicScope", + "pk": 1, + "fields": { + "name": "nationwide Change Healthcare network", + "created": "2022-01-01T00:00:00Z", + "modified": "2022-01-01T00:00:00Z" + } + }, + { + "model": "signals.DemographicScope", + "pk": 2, + "fields": { + "name": "nationwide Optum network", + "created": "2022-01-01T00:00:00Z", + "modified": "2022-01-01T00:00:00Z" + } + }, + { + "model": "signals.DemographicScope", + "pk": 3, + "fields": { + "name": "Adult Facebook users", + "created": "2022-01-01T00:00:00Z", + "modified": "2022-01-01T00:00:00Z" + } + }, + { + "model": "signals.DemographicScope", + "pk": 4, + "fields": { + "name": "Google search users", + "created": "2022-01-01T00:00:00Z", + "modified": "2022-01-01T00:00:00Z" + } + }, + { + "model": "signals.DemographicScope", + "pk": 5, + "fields": { + "name": "Smartphone users", + "created": "2022-01-01T00:00:00Z", + "modified": "2022-01-01T00:00:00Z" + } + } +] diff --git a/src/signals/admin.py b/src/signals/admin.py index f71546a..e2ddbbb 100644 --- a/src/signals/admin.py +++ b/src/signals/admin.py @@ -4,12 +4,14 @@ from import_export.admin import ImportExportModelAdmin from signals.models import ( + DemographicScope, Geography, GeographyUnit, Pathogen, Signal, SignalCategory, SignalType, + GeographySignal, ) from signals.resources import SignalBaseResource, SignalResource @@ -41,6 +43,15 @@ class GeographyAdmin(admin.ModelAdmin): search_fields: tuple[Literal['name']] = ('name',) +@admin.register(GeographySignal) +class GeographySignalAdmin(admin.ModelAdmin): + """ + Admin interface for managing signal geography objects. + """ + list_display: tuple[Literal['geography']] = ('geography', 'signal', 'aggregated_by_delphi') + search_fields: tuple[Literal['geography']] = ('geography', 'signal', 'aggregated_by_delphi') + + @admin.register(Pathogen) class PathogenAdmin(admin.ModelAdmin): """ @@ -59,6 +70,15 @@ class SignalTypeAdmin(admin.ModelAdmin): search_fields: tuple[Literal['name']] = ('name',) +@admin.register(DemographicScope) +class DemographicScopeAdmin(admin.ModelAdmin): + """ + Admin interface for managing demographic scope objects. + """ + list_display: tuple[Literal['name']] = ('name',) + search_fields: tuple[Literal['name']] = ('name',) + + @admin.register(Signal) class SignalAdmin(ImportExportModelAdmin): """ diff --git a/src/signals/migrations/0009_demographicscope_geographicscope_licence_and_more.py b/src/signals/migrations/0009_demographicscope_geographicscope_licence_and_more.py new file mode 100644 index 0000000..55b0a7e --- /dev/null +++ b/src/signals/migrations/0009_demographicscope_geographicscope_licence_and_more.py @@ -0,0 +1,173 @@ +# Generated by Django 5.0.3 on 2024-06-06 16:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0008_remove_geographyunit_postal'), + ] + + operations = [ + migrations.CreateModel( + name='DemographicScope', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text='Name', max_length=128, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='GeographicScope', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text='Name', max_length=128, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Licence', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text='Name', max_length=128, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Organisation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('organisation_name', models.CharField(help_text='Organisation Name', max_length=128, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.RemoveField( + model_name='signal', + name='available_geography', + ), + migrations.AddField( + model_name='signal', + name='age_breakdown', + field=models.CharField(choices=[('0-17', '0-17'), ('18-64', '18-64'), ('65+', '65+')], help_text='Age Breakdown', max_length=128, null=True), + ), + migrations.AddField( + model_name='signal', + name='data_censoring', + field=models.TextField(blank=True, help_text='Data Censoring', null=True), + ), + migrations.AddField( + model_name='signal', + name='gender_breakdown', + field=models.BooleanField(default=False, help_text='Gender Breakdown'), + ), + migrations.AddField( + model_name='signal', + name='missingness', + field=models.TextField(blank=True, help_text='Missingness', null=True), + ), + migrations.AddField( + model_name='signal', + name='race_breakdown', + field=models.BooleanField(default=False, help_text='Race Breakdown'), + ), + migrations.AddField( + model_name='signal', + name='reporting_cadence', + field=models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly')], help_text='Reporting Cadence', max_length=128, null=True), + ), + migrations.AddField( + model_name='signal', + name='restrictions', + field=models.TextField(blank=True, help_text='Restrictions', null=True), + ), + migrations.AddField( + model_name='signal', + name='severenity_pyramid_rungs', + field=models.CharField(choices=[('population', 'Population'), ('infected', 'Infected'), ('symptomatic', 'Symptomatic'), ('outpatient_visit', 'Outpatient visit'), ('ascertained', 'Ascertained (case)'), ('hospitalized', 'Hospitalized'), ('icu', 'ICU'), ('dead', 'Dead')], help_text='Severity Pyramid Rungs', max_length=128, null=True), + ), + migrations.AddField( + model_name='signal', + name='temporal_scope_end', + field=models.DateField(blank=True, help_text='Temporal Scope End', null=True), + ), + migrations.AddField( + model_name='signal', + name='temporal_scope_end_note', + field=models.TextField(blank=True, help_text='Temporal Scope End Note', null=True), + ), + migrations.AddField( + model_name='signal', + name='temporal_scope_start', + field=models.DateField(blank=True, help_text='Temporal Scope Start', null=True), + ), + migrations.AddField( + model_name='signal', + name='temporal_scope_start_note', + field=models.TextField(blank=True, help_text='Temporal Scope Start Note', null=True), + ), + migrations.AddField( + model_name='signal', + name='typical_reporting_lag', + field=models.CharField(blank=True, help_text='Typical Reporting Lag', max_length=128, null=True), + ), + migrations.AddField( + model_name='signal', + name='typical_revision_cadence', + field=models.CharField(blank=True, help_text='Typical Revision Cadence', max_length=512, null=True), + ), + migrations.AddField( + model_name='signal', + name='demographic_scope', + field=models.ManyToManyField(help_text='Demographic Scope', related_name='signals', to='signals.demographicscope'), + ), + migrations.AddField( + model_name='signal', + name='geographic_scope', + field=models.ManyToManyField(help_text='Geographic Scope', to='signals.geographicscope'), + ), + migrations.AddField( + model_name='signal', + name='licence', + field=models.ManyToManyField(help_text='Licence', related_name='signals', to='signals.licence'), + ), + migrations.AddField( + model_name='signal', + name='organisations_access_list', + field=models.ManyToManyField(help_text='Organisations Access List. Who may access this signal?', related_name='accessed_signals', to='signals.organisation'), + ), + migrations.AddField( + model_name='signal', + name='organisations_sharing_list', + field=models.ManyToManyField(help_text='Organisations Sharing List. Who may be told about this signal?', related_name='shared_signals', to='signals.organisation'), + ), + migrations.CreateModel( + name='GeographySignal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('aggregated_by_delphi', models.BooleanField(default=False)), + ('geography', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='signals.geography')), + ('signal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='signals.signal')), + ], + options={ + 'unique_together': {('geography', 'signal')}, + }, + ), + ] diff --git a/src/signals/migrations/0010_signal_available_geography.py b/src/signals/migrations/0010_signal_available_geography.py new file mode 100644 index 0000000..8bf7534 --- /dev/null +++ b/src/signals/migrations/0010_signal_available_geography.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-06-06 16:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0009_demographicscope_geographicscope_licence_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='signal', + name='available_geography', + field=models.ManyToManyField(help_text='Available geography', through='signals.GeographySignal', to='signals.geography'), + ), + ] diff --git a/src/signals/migrations/0011_alter_geographysignal_geography_and_more.py b/src/signals/migrations/0011_alter_geographysignal_geography_and_more.py new file mode 100644 index 0000000..798b121 --- /dev/null +++ b/src/signals/migrations/0011_alter_geographysignal_geography_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.3 on 2024-06-06 16:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0010_signal_available_geography'), + ] + + operations = [ + migrations.AlterField( + model_name='geographysignal', + name='geography', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='geography_signals', to='signals.geography'), + ), + migrations.AlterField( + model_name='geographysignal', + name='signal', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='geography_signals', to='signals.signal'), + ), + ] diff --git a/src/signals/migrations/0012_remove_signal_licence_signal_license_delete_licence.py b/src/signals/migrations/0012_remove_signal_licence_signal_license_delete_licence.py new file mode 100644 index 0000000..e9d6ef4 --- /dev/null +++ b/src/signals/migrations/0012_remove_signal_licence_signal_license_delete_licence.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2024-06-07 12:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0004_license'), + ('signals', '0011_alter_geographysignal_geography_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='signal', + name='licence', + ), + migrations.AddField( + model_name='signal', + name='license', + field=models.ForeignKey(help_text='License', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='signals', to='base.license'), + ), + migrations.DeleteModel( + name='Licence', + ), + ] diff --git a/src/signals/migrations/0013_remove_signal_signal_type_signal_signal_type.py b/src/signals/migrations/0013_remove_signal_signal_type_signal_signal_type.py new file mode 100644 index 0000000..4dc6cbf --- /dev/null +++ b/src/signals/migrations/0013_remove_signal_signal_type_signal_signal_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-06-07 14:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0012_remove_signal_licence_signal_license_delete_licence'), + ] + + operations = [ + migrations.RemoveField( + model_name='signal', + name='signal_type', + ), + migrations.AddField( + model_name='signal', + name='signal_type', + field=models.ForeignKey(help_text='Source Type', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='signals', to='signals.signaltype'), + ), + ] diff --git a/src/signals/migrations/0014_remove_signal_from_date_remove_signal_to_date.py b/src/signals/migrations/0014_remove_signal_from_date_remove_signal_to_date.py new file mode 100644 index 0000000..c975f5a --- /dev/null +++ b/src/signals/migrations/0014_remove_signal_from_date_remove_signal_to_date.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.10 on 2024-06-07 15:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0013_remove_signal_signal_type_signal_signal_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='signal', + name='from_date', + ), + migrations.RemoveField( + model_name='signal', + name='to_date', + ), + ] diff --git a/src/signals/migrations/0015_signal_from_date_signal_to_date_and_more.py b/src/signals/migrations/0015_signal_from_date_signal_to_date_and_more.py new file mode 100644 index 0000000..0034ced --- /dev/null +++ b/src/signals/migrations/0015_signal_from_date_signal_to_date_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.10 on 2024-06-07 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0014_remove_signal_from_date_remove_signal_to_date'), + ] + + operations = [ + migrations.AddField( + model_name='signal', + name='from_date', + field=models.DateField(blank=True, help_text='From Date', null=True), + ), + migrations.AddField( + model_name='signal', + name='to_date', + field=models.DateField(blank=True, help_text='To Date', null=True), + ), + migrations.AlterField( + model_name='signal', + name='temporal_scope_end', + field=models.CharField(blank=True, help_text='Temporal Scope End', max_length=128, null=True), + ), + migrations.AlterField( + model_name='signal', + name='temporal_scope_start', + field=models.CharField(blank=True, help_text='Temporal Scope Start', max_length=128, null=True), + ), + ] diff --git a/src/signals/migrations/0016_remove_signal_geographic_scope_and_more.py b/src/signals/migrations/0016_remove_signal_geographic_scope_and_more.py new file mode 100644 index 0000000..4800ef0 --- /dev/null +++ b/src/signals/migrations/0016_remove_signal_geographic_scope_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-06-10 13:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('signals', '0015_signal_from_date_signal_to_date_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='signal', + name='geographic_scope', + ), + migrations.AddField( + model_name='signal', + name='geographic_scope', + field=models.ForeignKey(help_text='Geographic Scope', null=True, on_delete=django.db.models.deletion.SET_NULL, to='signals.geographicscope'), + ), + ] diff --git a/src/signals/models.py b/src/signals/models.py index acbdfc8..5d3a6bf 100644 --- a/src/signals/models.py +++ b/src/signals/models.py @@ -12,6 +12,14 @@ class TimeTypeChoices(models.TextChoices): WEEK = 'week', _('Week') +class ReportingCadence(models.TextChoices): + """ + A class representing choices for reporting cadences. + """ + DAILY = 'daily', _('Daily') + WEEKLY = 'weekly', _('Weekly') + + class TimeLabelChoices(models.TextChoices): """ A class representing choices for time labels. @@ -49,6 +57,29 @@ class ActiveChoices(models.TextChoices): HISTORICAL = False, _('Historical') +class SeverityPyramidRungsChoices(models.TextChoices): + """ + A class representing choices for severity pyramid rungs. + """ + POPULATION = 'population', _('Population') + INFECTED = 'infected', _('Infected') + SYMPTOMATIC = 'symptomatic', _('Symptomatic') + OUTPATIENT_VISIT = 'outpatient_visit', _('Outpatient visit') + ASCERTAINED = 'ascertained', _('Ascertained (case)') + HOSPITALIZED = 'hospitalized', _('Hospitalized') + ICU = 'icu', _('ICU') + DEAD = 'dead', _('Dead') + + +class AgeBreakdownChoices(models.TextChoices): + """ + A class representing choices for age breakdown. + """ + CILDREN = '0-17', '0-17' + ADULTS = '18-64', '18-64' + SENIORS = '65+', '65+' + + class SignalCategory(TimeStampedModel): """ A model representing a signal category. @@ -137,6 +168,15 @@ def __str__(self) -> str: return str(self.name) +class GeographySignal(models.Model): + geography = models.ForeignKey('signals.Geography', on_delete=models.CASCADE, related_name='geography_signals') + signal = models.ForeignKey('signals.Signal', on_delete=models.CASCADE, related_name='geography_signals') + aggregated_by_delphi = models.BooleanField(default=False) + + class Meta: + unique_together = ('geography', 'signal') + + class GeographyUnit(TimeStampedModel): """ A model representing a geography (geo-level) unit. @@ -173,6 +213,57 @@ def __str__(self) -> str: return str(self.name) +class GeographicScope(TimeStampedModel): + """ + A model representing a geographic scope. + """ + name: models.CharField = models.CharField( + help_text=_('Name'), + max_length=128, + unique=True + ) + + def __str__(self) -> str: + """ + Returns the name of the geographic scope as a string. + + :return: The name of the geographic scope as a string. + :rtype: str + """ + return str(self.name) + + +class DemographicScope(TimeStampedModel): + """ + A model representing a demographic scope. + """ + name: models.CharField = models.CharField( + help_text=_('Name'), + max_length=128, + unique=True + ) + + def __str__(self) -> str: + """ + Returns the name of the demographic scope as a string. + + :return: The name of the demographic scope as a string. + :rtype: str + """ + return str(self.name) + + +class Organisation(TimeStampedModel): + """ + A model representing an access list. + """ + organisation_name: models.CharField = models.CharField( + help_text=_('Organisation Name'), + max_length=128, + unique=True + ) + + class Signal(TimeStampedModel): """ A model representing a signal. @@ -198,10 +289,12 @@ class Signal(TimeStampedModel): related_name='signals', help_text=_('Pathogen/Disease Area'), ) - signal_type: models.ManyToManyField = models.ManyToManyField( - 'signals.SignalType', + signal_type: models.ForeignKey = models.ForeignKey( + 'signals.signalType', related_name='signals', - help_text=_('Signal Type') + help_text=_('Source Type'), + on_delete=models.PROTECT, + null=True ) active: models.BooleanField = models.BooleanField( help_text=_('Active'), @@ -234,6 +327,35 @@ class Signal(TimeStampedModel): max_length=128, choices=TimeLabelChoices.choices ) + reporting_cadence: models.CharField = models.CharField( + help_text=_('Reporting Cadence'), + max_length=128, + choices=ReportingCadence.choices, + null=True + ) + typical_reporting_lag: models.CharField = models.CharField( + help_text=_('Typical Reporting Lag'), + max_length=128, + null=True, + blank=True + ) + typical_revision_cadence: models.CharField = models.CharField( + help_text=_('Typical Revision Cadence'), + max_length=512, + null=True, + blank=True + ) + demographic_scope: models.ManyToManyField = models.ManyToManyField( + 'signals.DemographicScope', + help_text=_('Demographic Scope'), + related_name='signals', + ) + severenity_pyramid_rungs: models.CharField = models.CharField( + help_text=_('Severity Pyramid Rungs'), + max_length=128, + choices=SeverityPyramidRungsChoices.choices, + null=True + ) category: models.ForeignKey = models.ForeignKey( 'signals.SignalCategory', related_name='signals', @@ -246,9 +368,50 @@ class Signal(TimeStampedModel): help_text=_('Signal links'), related_name="signals" ) + geographic_scope: models.ForeignKey = models.ForeignKey( + 'signals.GeographicScope', + help_text=_('Geographic Scope'), + on_delete=models.SET_NULL, + null=True + ) available_geography: models.ManyToManyField = models.ManyToManyField( 'signals.Geography', - help_text=_('Available geography') + help_text=_('Available geography'), + through='signals.GeographySignal' + ) + temporal_scope_start: models.DateField = models.DateField( + help_text=_('Temporal Scope Start'), + null=True, + blank=True + ) + temporal_scope_start_note = models.TextField( + help_text=_('Temporal Scope Start Note'), + null=True, + blank=True + ) + temporal_scope_end: models.DateField = models.DateField( + help_text=_('Temporal Scope End'), + null=True, + blank=True + ) + temporal_scope_end_note = models.TextField( + help_text=_('Temporal Scope End Note'), + null=True, + blank=True + ) + gender_breakdown: models.BooleanField = models.BooleanField( + help_text=_('Gender Breakdown'), + default=False + ) + race_breakdown: models.BooleanField = models.BooleanField( + help_text=_('Race Breakdown'), + default=False, + ) + age_breakdown: models.CharField = models.CharField( + help_text=_('Age Breakdown'), + max_length=128, + choices=AgeBreakdownChoices.choices, + null=True, ) is_smoothed: models.BooleanField = models.BooleanField( help_text=_('Is Smoothed'), @@ -281,6 +444,42 @@ class Signal(TimeStampedModel): help_text=_('Source Subdivision'), on_delete=models.PROTECT, ) + data_censoring: models.TextField = models.TextField( + help_text=_('Data Censoring'), + null=True, + blank=True + ) + missingness: models.TextField = models.TextField( + help_text=_('Missingness'), + null=True, + blank=True + ) + organisations_access_list: models.ManyToManyField = models.ManyToManyField( + 'signals.Organisation', + help_text=_('Organisations Access List. Who may access this signal?'), + related_name='accessed_signals' + ) + + organisations_sharing_list: models.ManyToManyField = models.ManyToManyField( + 'signals.Organisation', + help_text=_('Organisations Sharing List. Who may be told about this signal?'), + related_name='shared_signals' + ) + + license: models.ForeignKey = models.ForeignKey( + 'base.License', + related_name='signals', + help_text=_('License'), + on_delete=models.PROTECT, + null=True + ) + + restrictions: models.TextField = models.TextField( + help_text=_('Restrictions'), + null=True, + blank=True + ) + last_updated: models.DateField = models.DateField( help_text=_('Last Updated'), null=True, @@ -297,17 +496,58 @@ class Signal(TimeStampedModel): blank=True ) + temporal_scope_start: models.CharField = models.CharField( + help_text=_('Temporal Scope Start'), + null=True, + blank=True, + max_length=128 + ) + temporal_scope_end: models.CharField = models.CharField( + help_text=_('Temporal Scope End'), + null=True, + blank=True, + max_length=128 + ) + + @property + def is_access_public(self) -> bool: + """ + Returns True if the signal is public, False otherwise. + + :return: True if the signal is public, False otherwise. + :rtype: bool + """ + return self.organisations_access_list.count() == 0 + + def is_sharing_public(self) -> bool: + """ + Returns True if the signal is public, False otherwise. + + :return: True if the signal is public, False otherwise. + :rtype: bool + """ + return self.organisations_sharing_list.count() == 0 + @property - def example_url(self): + def example_url(self) -> str | None: + """ + Returns the example URL of the signal. + + :return: The example URL of the signal. + :rtype: str | None + """ example_url = self.links.filter(link_type="example_url").first() return example_url.url if example_url else None @property - def same_base_signals(self): + def has_all_demographic_scopes(self) -> bool: """ - Returns the signals that have the same base signal. + Returns True if the signal has all demographic scopes, False otherwise. + + :return: True if the signal has all demographic scopes, False otherwise. + :rtype: bool """ - return self.base.base_for.all() if self.base else None + return self.demographic_scope.count() == DemographicScope.objects.count() class Meta: unique_together = ['name', 'source'] diff --git a/src/signals/resources.py b/src/signals/resources.py index ffed940..4a28e50 100644 --- a/src/signals/resources.py +++ b/src/signals/resources.py @@ -5,14 +5,18 @@ from import_export import resources from import_export.fields import Field, widgets -from base.models import Link, LinkTypeChoices +from base.models import Link, LinkTypeChoices, License from datasources.models import SourceSubdivision from signals.models import ( + DemographicScope, Geography, + GeographySignal, + Organisation, Pathogen, Signal, SignalCategory, SignalType, + GeographicScope, ) @@ -67,14 +71,23 @@ class SignalResource(resources.ModelResource): signal_type = Field( attribute='signal_type', column_name='Signal Type', - widget=widgets.ManyToManyWidget(SignalType, field='name', separator=','), + widget=widgets.ForeignKeyWidget(SignalType, field='name'), ) active = Field(attribute='active', column_name='Active') short_description = Field(attribute='short_description', column_name='Short Description') description = Field(attribute='description', column_name='Description') - format = Field(attribute='format', column_name='Format') + format_type = Field(attribute='format_type', column_name='Format') time_type = Field(attribute='time_type', column_name='Time Type') time_label = Field(attribute='time_label', column_name='Time Label') + typical_reporting_lag = Field(attribute='typical_reporting_lag', column_name='Typical Reporting Lag') + typical_revision_cadence = Field(attribute='typical_revision_cadence', column_name='Typical Revision Cadence') + reporting_cadence = Field(attribute='reporting_cadence', column_name='Reporting Cadence') + demographic_scope = Field( + attribute='demographic_scope', + column_name='Demographic Scope', + widget=widgets.ManyToManyWidget(DemographicScope, field='name', separator=','), + ) + severenity_pyramid_rungs = Field(attribute='severenity_pyramid_rungs', column_name='Severity Pyramid Rungs') category = Field( attribute='category', column_name='Category', @@ -85,6 +98,11 @@ class SignalResource(resources.ModelResource): column_name='Available Geography', widget=widgets.ManyToManyWidget(Geography, field='name', separator=','), ) + # delphi_aggregated_geography = Field( + # attribute='delphi_aggregated_geography', + # column_name='Delphi-Aggregated Geography', + # widget=widgets.ManyToManyWidget(Geography, field='name', separator=','), + # ) is_smoothed = Field(attribute='is_smoothed', column_name='Is Smoothed') is_weighted = Field(attribute='is_weighted', column_name='Is Weighted') is_cumulative = Field(attribute='is_cumulative', column_name='Is Cumulative') @@ -96,11 +114,45 @@ class SignalResource(resources.ModelResource): column_name='Source Subdivision', widget=widgets.ForeignKeyWidget(SourceSubdivision, field='name'), ) + data_censoring = Field(attribute='data_censoring', column_name='Data Censoring') + missingness = Field(attribute='missingness', column_name='Missingness') + organisations_access_list = Field( + attribute='organisations_access_list', + column_name='Who may access this signal?', + widget=widgets.ManyToManyWidget(Organisation, field='organisation_name', separator=','), + ) + organisations_sharing_list = Field( + attribute='organisations_sharing_list', + column_name='Who may be told about this signal?', + widget=widgets.ManyToManyWidget(Organisation, field='organisation_name', separator=','), + ) + restrictions = Field(attribute='restrictions', column_name='Use Restrictions') + license = Field( + attribute='license', + column_name='License', + widget=widgets.ForeignKeyWidget(License, field='name'), + ) links = Field( attribute='links', column_name='Links', widget=widgets.ManyToManyWidget(Link, field='url', separator='|'), ) + temporal_scope_start = Field( + attribute='temporal_scope_start', + column_name='Temporal Scope Start', + ) + temporal_scope_end = Field( + attribute='temporal_scope_end', + column_name='Temporal Scope End', + ) + geographic_scope = Field( + attribute='geographic_scope', + column_name='Geographic Scope', + widget=widgets.ForeignKeyWidget(GeographicScope, field='name'), + ) + # gender_breakdown = Field(attribute='gender_breakdown', column_name='Gender Breakdown') + # race_breakdown = Field(attribute='race_breakdown', column_name='Race Breakdown') + # age_breakdown = Field(attribute='age_breakdown', column_name='Age Breakdown') class Meta: model = Signal @@ -112,9 +164,12 @@ class Meta: 'active', 'short_description', 'description', - 'format', + 'format_type', 'time_type', 'time_label', + 'reporting_cadence', + 'demographic_scope', + 'severenity_pyramid_rungs', 'available_geography', 'is_smoothed', 'is_weighted', @@ -123,18 +178,41 @@ class Meta: 'has_sample_size', 'high_values_are', 'source', - 'links' + 'links', + 'data_censoring', + 'missingness', + 'temporal_scope_start', + 'temporal_scope_end', + 'geographic_scope', + # 'gender_breakdown', + # 'race_breakdown', + # 'age_breakdown', ] import_id_fields: list[str] = ['name', 'source', 'display_name'] + store_instance = True def before_import_row(self, row, **kwargs) -> None: """ Pre-processes each row before importing. """ - self.fix_boolean_fields(row, ['Active', 'Is Smoothed', 'Is Weighted', 'Is Cumulative', 'Has StdErr', 'Has Sample Size']) + self.fix_boolean_fields(row, [ + 'Active', + 'Is Smoothed', + 'Is Weighted', + 'Is Cumulative', + 'Has StdErr', + 'Has Sample Size', + # 'gender_breakdown', + # 'race_breakdown', + ]) self.process_links(row) self.process_pathogen(row) + self.process_license(row) + self.process_signal_category(row) + self.process_signal_type(row) + self.process_geographic_scope(row) + self.process_demographic_scope(row) def is_url_in_domain(self, url, domain) -> Any: """ @@ -193,3 +271,111 @@ def process_pathogen(self, row) -> None: pathogens: str = row['Pathogen/ Disease Area'].split(',') for pathogen in pathogens: Pathogen.objects.get_or_create(name=pathogen.strip()) + + def process_demographic_scope(self, row) -> None: + """ + Processes demographic scope. + """ + + if row['Demographic Scope']: + if row['Demographic Scope'] == 'All': + DemographicScope.objects.all() + else: + demographic_scopes: str = row['Demographic Scope'].split(',') + for demographic_scope in demographic_scopes: + DemographicScope.objects.get_or_create(name=demographic_scope.strip()) + + def process_severenity_pyramid_rungs(self, row) -> None: + """ + Processes severenity pyramid rungs. + """ + + if row['Severity Pyramid Rungs']: + if row['Severity Pyramid Rungs'].startswith('None'): + row['Severity Pyramid Rungs'] = None + + def process_organisations_access_list(self, row): + """ + Processes organisations access list. + """ + + if row['Who may access this signal?']: + if row['Who may access this signal?'] == 'public': + organisations = [] + else: + organisations: str = row['Who may access this signal?'].split(',') + for organisation in organisations: + Organisation.objects.get_or_create(organisation_name=organisation.strip()) + + def process_organisations_sharing_list(self, row): + """ + Processes organisations sharing list. + """ + + if row['Who may be told about this signal?']: + if row['Who may be told about this signal?'] == 'public': + organisations = [] + else: + organisations: str = row['Who may be told about this signal?'].split(',') + for organisation in organisations: + Organisation.objects.get_or_create(organisation_name=organisation.strip()) + + def process_license(self, row): + """ + Processes license. + """ + + if row['License']: + license, created = License.objects.get_or_create(name=row['License']) + license.use_restrictions = row['Use Restrictions'] + license.save() + row['License'] = license + + def process_signal_category(self, row): + """ + Processes signal category. + """ + + if row['Category']: + category, created = SignalCategory.objects.get_or_create(name=row['Category']) + row['Category'] = category + + def process_signal_type(self, row): + if row['Signal Type']: + signal_type, created = SignalType.objects.get_or_create(name=row['Signal Type']) + row['Signal Type'] = signal_type + + def process_geographic_scope(self, row): + if row['Geographic Scope']: + geographic_scope, created = GeographicScope.objects.get_or_create(name=row['Geographic Scope']) + row['Geographic Scope'] = geographic_scope + + def after_import_row(self, row, row_result, **kwargs) -> None: + """ + Post-processes each row after importing. + """ + geographies: str = row['Available Geography'].split(',') + delphi_aggregated_geographies: str = row['Delphi-Aggregated Geography'].split(',') + for geography in geographies: + geography_instance, _ = Geography.objects.get_or_create(name=geography.strip()) + if geography in delphi_aggregated_geographies: + signal = Signal.objects.get(id=row_result.object_id) + geography_signal, _ = GeographySignal.objects.get_or_create(geography=geography_instance, signal=signal) + geography_signal.aggregated_by_delphi = True + geography_signal.save() + + # def process_available_geography(self, row): + # """ + # Processes available geography. + # """ + + # if row['Available Geography']: + # geographies: str = row['Available Geography'].split(',') + # delphi_aggregated_geographies: str = row['Delphi-Aggregated Geography'].split(',') + # for geography in geographies: + # geography_instance = Geography.objects.get_or_create(name=geography.strip()) + # if geography in delphi_aggregated_geographies: + # signal = Signal.objects.get(name=row['Signal']) + # signal_geography = GeographySignal.objects.filter(geography=geography_instance, signal=signal).first() + # signal_geography.delphi_aggregated = True + # signal_geography.save() diff --git a/src/signals/tasks.py b/src/signals/tasks.py index e8ec324..46e61f0 100644 --- a/src/signals/tasks.py +++ b/src/signals/tasks.py @@ -2,6 +2,7 @@ import os import requests +from rest_framework.status import is_client_error, is_server_error from signal_documentation.celery import BaseTaskWithRetry, app from signals.tools import SignalLastUpdatedParser @@ -12,8 +13,8 @@ @app.task(bind=BaseTaskWithRetry) def get_covidcast_meta(self): response = requests.get(COVID_CAST_META_URL, timeout=5) - if response is None: - return f'Not response, url {COVID_CAST_META_URL}' + if is_client_error(response.status_code) or is_server_error(response.status_code): + return f'{COVID_CAST_META_URL}. Error: {response.status_code} - {response.content}' if response.status_code == 200: parser = SignalLastUpdatedParser(covidcast_meta_data=json.loads(response.content)) diff --git a/src/templates/signals/signal_detail.html b/src/templates/signals/signal_detail.html index 6ea20a2..bec7e6f 100644 --- a/src/templates/signals/signal_detail.html +++ b/src/templates/signals/signal_detail.html @@ -100,13 +100,11 @@
About this signal
Signal Type