Skip to content

Commit e1c9af3

Browse files
authored
Merge pull request #42 from cmu-delphi/API-endpoints-#41
Api endpoints #41
2 parents 187b678 + 4105db2 commit e1c9af3

File tree

18 files changed

+644
-185
lines changed

18 files changed

+644
-185
lines changed

Pipfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ django-extensions-models = "*"
2727
mypy = "*"
2828
django-stubs = "*"
2929
tzdata = "*"
30+
djangorestframework = "*"
3031
pyparsing = "*"
3132
pydot = "*"
33+
markdown = "*"
34+
drf-spectacular = "*"
3235
django-docs = "*"
3336
sphinxcontrib-django = "*"
3437
sphinx-rtd-theme = "*"

Pipfile.lock

Lines changed: 351 additions & 167 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

git

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/base/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from rest_framework.serializers import ModelSerializer
2+
3+
from base.models import Link
4+
5+
6+
class LinkSerializer(ModelSerializer):
7+
"""
8+
Serializer for the Link model.
9+
"""
10+
class Meta:
11+
model = Link
12+
fields = ['link_type', 'url']

src/signal_documentation/settings.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@
8181
'debug_toolbar',
8282
'django_extensions',
8383
'models_extensions',
84+
'rest_framework',
85+
'drf_spectacular',
8486
'django_filters',
8587
'health_check',
8688
'health_check.db',
@@ -151,6 +153,53 @@
151153
}
152154
}
153155

156+
157+
PAGE_SIZE = os.environ.get('PAGE_SIZE', 10)
158+
159+
160+
# Django REST framework
161+
# https://www.django-rest-framework.org/
162+
REST_FRAMEWORK = {
163+
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
164+
"PAGE_SIZE": os.environ.get('PAGE_SIZE', PAGE_SIZE),
165+
'DEFAULT_RENDERER_CLASSES': [
166+
'rest_framework.renderers.JSONRenderer',
167+
'rest_framework.renderers.BrowsableAPIRenderer',
168+
],
169+
'DEFAULT_PARSER_CLASSES': [
170+
'rest_framework.parsers.JSONParser',
171+
'rest_framework.parsers.MultiPartParser',
172+
],
173+
'DEFAULT_AUTHENTICATION_CLASSES': [
174+
'rest_framework.authentication.BasicAuthentication',
175+
'rest_framework.authentication.SessionAuthentication',
176+
],
177+
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
178+
'DEFAULT_FILTER_BACKENDS': (
179+
'django_filters.rest_framework.DjangoFilterBackend',
180+
),
181+
}
182+
183+
184+
# DRF Spectacular settings
185+
# https://drf-spectacular.readthedocs.io/en/latest/settings.html
186+
SPECTACULAR_SETTINGS = {
187+
'TITLE': 'Signal Documentation',
188+
'DESCRIPTION': 'Signal Documentation API',
189+
'VERSION': '1.0.0',
190+
"COMPONENT_SPLIT_PATCH": True,
191+
"COMPONENT_SPLIT_REQUEST": True,
192+
'SERVE_PUBLIC': True,
193+
'SCHEMA_PATH_PREFIX': '/api/v[0-9]',
194+
'SWAGGER_UI_SETTINGS': {
195+
'docExpansion': 'list',
196+
'filter': True,
197+
'tagsSorter': 'alpha',
198+
},
199+
'SERVE_INCLUDE_SCHEMA': False,
200+
}
201+
202+
154203
# Django chache
155204
# https://docs.djangoproject.com/en/4.2/topics/cache/#redis
156205

src/signal_documentation/urls.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
include,
77
path,
88
)
9+
from drf_spectacular.views import (
10+
SpectacularAPIView,
11+
SpectacularRedocView,
12+
SpectacularSwaggerView,
13+
)
914

1015
from base.views import (
1116
BadRequestErrorView,
@@ -23,7 +28,23 @@
2328
path(f'{settings.MAIN_PAGE}/admin/' if settings.MAIN_PAGE else 'admin/', admin.site.urls),
2429
path('__debug__/', include('debug_toolbar.urls')),
2530
path(f'{settings.MAIN_PAGE}/' if settings.MAIN_PAGE else '', include('signals.urls')),
31+
32+
# sphinx docs
2633
path(f'{settings.MAIN_PAGE}/docs/' if settings.MAIN_PAGE else 'docs/', include('docs.urls')),
34+
35+
# drf-spectacular
36+
path(
37+
f'{settings.MAIN_PAGE}/api/docs/schema/' if settings.MAIN_PAGE else 'api/docs/schema/',
38+
SpectacularAPIView.as_view(), name="spectacular-schema"
39+
),
40+
path(
41+
f'{settings.MAIN_PAGE}/api/docs/swagger/' if settings.MAIN_PAGE else 'api/docs/swagger/',
42+
SpectacularSwaggerView.as_view(url_name="spectacular-schema"), name="swagger"
43+
),
44+
path(
45+
f'{settings.MAIN_PAGE}/api/docs/redoc/' if settings.MAIN_PAGE else 'api/docs/redoc/',
46+
SpectacularRedocView.as_view(url_name="spectacular-schema"), name="redoc"
47+
),
2748
]
2849

2950
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # type: ignore

src/signals/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ class SignalAdmin(ImportExportModelAdmin):
5757
list_display: tuple[Literal['name']] = ('name',)
5858
search_fields: tuple[Literal['name'], Literal['description'], Literal['short_description']]
5959
search_fields = ('name', 'description', 'short_description')
60-
list_filter: tuple[Literal['pathogen'], Literal['available_geography'], Literal['signal_type'], Literal['format'],
60+
list_filter: tuple[Literal['pathogen'], Literal['available_geography'], Literal['signal_type'], Literal['format_type'],
6161
Literal['is_smoothed'], Literal['is_weighted'], Literal['is_cumulative'], Literal['has_stderr'], Literal['has_sample_size']]
6262
list_filter = (
6363
'pathogen',
6464
'available_geography',
6565
'signal_type',
66-
'format',
66+
'format_type',
6767
'is_smoothed',
6868
'is_weighted',
6969
'is_cumulative',

src/signals/filters.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99

1010
class SignalFilter(django_filters.FilterSet):
11+
"""
12+
FilterSet for the Signal model.
13+
"""
14+
1115
search = CharFilter(method='filter_search')
1216

1317
class Meta:
@@ -18,12 +22,24 @@ class Meta:
1822
'available_geography',
1923
'signal_type',
2024
'category',
21-
'format',
25+
'format_type',
2226
'source',
2327
'time_label',
2428
]
2529

2630
def filter_search(self, queryset, name, value) -> Any:
31+
"""
32+
Custom filter method to perform a search on the Signal model.
33+
34+
Args:
35+
queryset (QuerySet): The initial queryset.
36+
name (str): The name of the filter field.
37+
value (Any): The value to search for.
38+
39+
Returns:
40+
QuerySet: The filtered queryset based on the search value.
41+
"""
42+
2743
if not value:
2844
return queryset
2945

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.6 on 2023-11-01 16:40
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('signals', '0003_alter_signal_options'),
10+
]
11+
12+
operations = [
13+
migrations.RenameField(
14+
model_name='signal',
15+
old_name='format',
16+
new_name='format_type',
17+
),
18+
]

src/signals/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ class Signal(TimeStampedModel):
177177
null=True,
178178
blank=True
179179
)
180-
format = models.CharField(
180+
format_type = models.CharField(
181181
help_text=_('Format'),
182182
max_length=128,
183183
choices=FormatChoices.choices

src/signals/resources.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717

1818

1919
class SignalBaseResource(resources.ModelResource):
20+
"""
21+
Resource class for importing Signals base.
22+
"""
23+
2024
name = Field(attribute='name', column_name='Signal')
2125
display_name = Field(attribute='display_name', column_name='Name')
2226
base = Field(
@@ -49,7 +53,9 @@ def process_base(self, row) -> None:
4953

5054

5155
class SignalResource(resources.ModelResource):
52-
"""Resource class for importing and exporting Signal models."""
56+
"""
57+
Resource class for importing and exporting Signal models
58+
"""
5359

5460
name = Field(attribute='name', column_name='Signal')
5561
display_name = Field(attribute='display_name', column_name='Name')
@@ -122,19 +128,26 @@ class Meta:
122128
import_id_fields: list[str] = ['name', 'source', 'display_name']
123129

124130
def before_import_row(self, row, **kwargs) -> None:
125-
"""Pre-processes each row before importing."""
131+
"""
132+
Pre-processes each row before importing.
133+
"""
134+
126135
self.fix_boolean_fields(row, ['Active', 'Is Smoothed', 'Is Weighted', 'Is Cumulative', 'Has StdErr', 'Has Sample Size'])
127136
self.process_links(row)
128137
self.process_pathogen(row)
129138

130139
def is_url_in_domain(self, url, domain) -> Any:
131-
"""Checks if a URL belongs to a specific domain."""
140+
"""
141+
Checks if a URL belongs to a specific domain.
142+
"""
132143

133144
parsed_url: Any = urlparse(url)
134145
return parsed_url.netloc == domain
135146

136147
def fix_boolean_fields(self, row, fields: list) -> Any:
137-
"""Fixes boolean fields."""
148+
"""
149+
Fixes boolean fields.
150+
"""
138151

139152
for k in fields:
140153
if row[k] == 'TRUE':
@@ -144,7 +157,9 @@ def fix_boolean_fields(self, row, fields: list) -> Any:
144157
return row
145158

146159
def process_links(self, row) -> Any:
147-
"""Processes links."""
160+
"""
161+
Processes links.
162+
"""
148163

149164
row['Links'] = ''
150165
if row['Link']:
@@ -170,7 +185,9 @@ def process_links(self, row) -> Any:
170185
return row
171186

172187
def process_pathogen(self, row) -> None:
173-
"""Processes pathogen."""
188+
"""
189+
Processes pathogen.
190+
"""
174191

175192
if row['Pathogen/ Disease Area']:
176193
pathogens: str = row['Pathogen/ Disease Area'].split(',')

src/signals/serializers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from rest_framework.serializers import ModelSerializer, SlugRelatedField
2+
3+
from base.serializers import LinkSerializer
4+
from signals.models import Signal
5+
6+
7+
class SignalBaseSerializer(ModelSerializer):
8+
"""
9+
Serializer for the base Signal model.
10+
"""
11+
12+
class Meta:
13+
model = Signal
14+
fields = ['id', 'name', 'display_name']
15+
16+
17+
class SignalSerializer(ModelSerializer):
18+
"""
19+
Serializer for the Signal model.
20+
"""
21+
22+
links = LinkSerializer(many=True)
23+
pathogen = SlugRelatedField(many=True, read_only=True, slug_field='name')
24+
signal_type = SlugRelatedField(many=True, read_only=True, slug_field='name')
25+
available_geography = SlugRelatedField(many=True, read_only=True, slug_field='name')
26+
category = SlugRelatedField(read_only=True, slug_field='name')
27+
source = SlugRelatedField(read_only=True, slug_field='name')
28+
base = SignalBaseSerializer()
29+
30+
class Meta:
31+
model = Signal
32+
fields = '__all__'

src/signals/tests/factories.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ class SignalFactory(DjangoModelFactory):
7171
active = fake.boolean()
7272
short_description = fake.text(max_nb_chars=500)
7373
description = fake.text(max_nb_chars=500)
74-
format = fake.random_element(FormatChoices.values)
74+
format_type = fake.random_element(FormatChoices.values)
7575
time_type = fake.random_element(TimeTypeChoices.values)
7676
time_label = fake.random_element(TimeLabelChoices.values)
7777
category = SubFactory(SignalCategoryFactory)

src/signals/tests/test_api.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from django.urls import reverse
2+
from faker import Faker
3+
from rest_framework.test import APITestCase
4+
5+
from signals.models import Signal
6+
from signals.tests.factories import SignalFactory
7+
8+
fake = Faker()
9+
10+
11+
class SignalListApiViewTest(APITestCase):
12+
13+
def setUp(self):
14+
for i in range(fake.random_int(min=1, max=100)):
15+
SignalFactory()
16+
17+
def test_signal_list_api_view(self):
18+
response = self.client.get(reverse('signals_api'))
19+
self.assertEqual(response.status_code, 200)
20+
self.assertEqual(response.data['count'], Signal.objects.count())
21+
22+
def test_signal_list_api_view_filters(self):
23+
signal = Signal.objects.order_by("?").first()
24+
signal.base = signal
25+
signal.save()
26+
response = self.client.get(reverse('signals_api'), {'name': signal.name})
27+
for result in response.json()['results']:
28+
self.assertEqual(signal.name, result['name'])
29+
response = self.client.get(reverse('signals_api'), {'pathogen__name': signal.pathogen.first().name})
30+
for result in response.json()['results']:
31+
self.assertTrue(signal.pathogen.first().name in result['pathogen'])
32+
response = self.client.get(reverse('signals_api'), {'available_geography__name': signal.available_geography.first().name})
33+
for result in response.json()['results']:
34+
self.assertTrue(signal.available_geography.first().name in result['available_geography'])
35+
response = self.client.get(reverse('signals_api'), {'signal_type__name': signal.signal_type.first().name})
36+
for result in response.json()['results']:
37+
self.assertTrue(signal.signal_type.first().name in result['signal_type'])
38+
response = self.client.get(reverse('signals_api'), {'category__name': signal.category.name})
39+
for result in response.json()['results']:
40+
self.assertTrue(signal.category.name in result['category'])
41+
response = self.client.get(reverse('signals_api'), {'source__name': signal.source.name})
42+
for result in response.json()['results']:
43+
self.assertTrue(signal.source.name in result['source'])
44+
response = self.client.get(reverse('signals_api'), {'time_label': signal.time_label})
45+
for result in response.json()['results']:
46+
self.assertEqual(signal.time_label, result['time_label'])
47+
response = self.client.get(reverse('signals_api'), {'format_type': signal.format_type})
48+
for result in response.json()['results']:
49+
self.assertEqual(signal.format_type, result['format_type'])
50+
response = self.client.get(reverse('signals_api'), {'base': signal.base.id})
51+
for result in response.json()['results']:
52+
self.assertEqual(signal.base.id, result['base']['id'])

src/signals/urls.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
from django.urls import path
22
from django.urls.resolvers import URLPattern
33

4-
from signals.views import SignalsDetailView, SignalsListView
4+
from signals.views import (
5+
SignalsDetailView,
6+
SignalsListApiView,
7+
SignalsListView,
8+
)
59

610
urlpatterns: list[URLPattern] = [
711
path('', SignalsListView.as_view(), name='signals'),
812
path('signals/<pk>/', SignalsDetailView.as_view(), name='signal'),
13+
14+
# REST API
15+
path('api/v1/signals/', SignalsListApiView.as_view(), name='signals_api'),
16+
917
]

0 commit comments

Comments
 (0)