Skip to content
This repository was archived by the owner on Jun 1, 2022. It is now read-only.

Commit 6b6ac30

Browse files
committed
Initial attempt at a data model and API for capturing availability.
- Models for representing availability reports and availability windows. - REST API endpoint for writing those availabiltiy reports. - Basic test that the API endpoint works.
1 parent 7aefb8c commit 6b6ac30

File tree

12 files changed

+656
-31
lines changed

12 files changed

+656
-31
lines changed

docs/api.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,62 @@ A tool for trying out this API is available at https://vaccinateca-preview.herok
8383
Anything submitted using that tool will have `is_test_data` set to True in the database.
8484

8585
You can view test reports here: https://vaccinateca-preview.herokuapp.com/admin/core/report/?is_test_data__exact=1
86+
87+
## api/submitAvailabilityReport
88+
89+
This endpoint records an availability report for a location, which generally includes a list of known appointment windows.
90+
It will usually be used by an automated script.
91+
92+
This API endpoint is called with an HTTP post, with JSON in teh POST body.
93+
The `SCRAPER_API_KEY` should be sent in the header as `Authorization: Bearer <SCRAPER_API_KEY>`.
94+
95+
The JSON document must have the following keys:
96+
- `feed_update`: an object with three keys: `uuid`, `github_url`, and `feed_provider`.
97+
This should be the same for all reports submitted in a single session (e.g., as a result of a single feed update or scrape).
98+
The `uuid` should be generated by the client (e.g., using Python's `uuid.uuid4()` method).
99+
The `github_url` is a URL to our repository on GitHub where the raw data can be inspected.
100+
The `feed_provider` is a slug that uniquely refers to this particular data source (e.g., `curative` for Curative's JSON feed).
101+
Provider slugs are available in the `Feed provider` table.
102+
- `location`. This is a unique identifier to the location _used by this feed provider_.
103+
It is not the `public_id` of the location.
104+
A pre-submitted concordance is used to map this to a location in our database.
105+
- `availability_windows` is a list of objects, each of which has the fields `starts_at`, `ends_at`, `slots`, and `additional_restrictions`.
106+
The `starts_at` and `ends_at` fields are timestamps that indicate the bounds of this window.
107+
The `slots` field indicates the number of currently available slots in this window.
108+
The `additional_restrictions` field is a list of availability tags, using their slugs (see above).
109+
110+
Optionally, the JSON document may include a `feed_json` key, with a value consisting of the raw JSON (e.g., from a provider's API)
111+
used to inform the availability report _for this location_.
112+
113+
For example:
114+
```json
115+
{
116+
"feed_update": {
117+
"uuid": "02d63a35-5dbc-4ac8-affb-14603bf6eb2e",
118+
"github_url": "https://example.com",
119+
"feed_provider": "test_provider"
120+
},
121+
"location": "116",
122+
"availability_windows": [
123+
{
124+
"starts_at": "2021-02-28T10:00:00Z",
125+
"ends_at": "2021-02-28T11:00:00Z",
126+
"slots": 25,
127+
"additional_restrictions": []
128+
},
129+
{
130+
"starts_at": "2021-02-28T11:00:00Z",
131+
"ends_at": "2021-02-28T12:00:00Z",
132+
"slots": 18,
133+
"additional_restrictions": [
134+
"vaccinating_65_plus"
135+
]
136+
}
137+
]
138+
}
139+
```
140+
141+
If the request was successful, it returns a 201 Created HTTP response.
142+
143+
A common reason for an unsuccessful request is the lack of concordance between the provider's location ID and our known
144+
locations. In general, an automated process should find and flag new locations in each scrape before publshing availability.

vaccinate/api/migrations/0001_initial.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 3.1.7 on 2021-03-03 20:19
1+
# Generated by Django 3.1.7 on 2021-03-04 05:34
22

33
import core.fields
44
from django.db import migrations, models
@@ -11,7 +11,7 @@ class Migration(migrations.Migration):
1111
initial = True
1212

1313
dependencies = [
14-
("core", "0031_auto_20210303_2019"),
14+
("core", "0031_auto_20210304_0534"),
1515
]
1616

1717
operations = [
@@ -88,6 +88,17 @@ class Migration(migrations.Migration):
8888
blank=True, help_text="Response body if it was JSON", null=True
8989
),
9090
),
91+
(
92+
"created_availability_report",
93+
models.ForeignKey(
94+
blank=True,
95+
help_text="Availability report that was created by this API call, if any",
96+
null=True,
97+
on_delete=django.db.models.deletion.SET_NULL,
98+
related_name="created_by_api_logs",
99+
to="core.appointmentavailabilityreport",
100+
),
101+
),
91102
(
92103
"created_report",
93104
models.ForeignKey(

vaccinate/api/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ class ApiLog(models.Model):
3737
on_delete=models.SET_NULL,
3838
help_text="Report that was created by this API call, if any",
3939
)
40+
created_availability_report = models.ForeignKey(
41+
"core.AppointmentAvailabilityReport",
42+
null=True,
43+
blank=True,
44+
related_name="created_by_api_logs",
45+
on_delete=models.SET_NULL,
46+
help_text="Availability report that was created by this API call, if any",
47+
)
4048

4149
class Meta:
4250
db_table = "api_log"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"location": "recaQlVkkI1rNarvx",
3+
"input": {
4+
"feed_update": {
5+
"uuid": "02d63a35-5dbc-4ac8-affb-14603bf6eb2e",
6+
"github_url": "https://example.com",
7+
"feed_provider": "test_provider"
8+
},
9+
"location": "116",
10+
"availability_windows": [
11+
{
12+
"starts_at": "2021-02-28T10:00:00Z",
13+
"ends_at": "2021-02-28T11:00:00Z",
14+
"slots": 25,
15+
"additional_restrictions": []
16+
},
17+
{
18+
"starts_at": "2021-02-28T11:00:00Z",
19+
"ends_at": "2021-02-28T12:00:00Z",
20+
"slots": 18,
21+
"additional_restrictions": ["vaccinating_65_plus"]
22+
}
23+
]
24+
},
25+
"expected_status": 201,
26+
"expected_fields": {
27+
"location__public_id": "recaQlVkkI1rNarvx"
28+
},
29+
"expected_windows": [
30+
{
31+
"expected_fields":
32+
{
33+
"slots": 25
34+
},
35+
"expected_additional_restrictions": []
36+
},
37+
{
38+
"expected_fields":
39+
{
40+
"slots": 18
41+
},
42+
"expected_additional_restrictions": ["vaccinating_65_plus"]
43+
}
44+
]
45+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from config.settings import SCRAPER_API_KEY
2+
from core.models import (
3+
Report,
4+
Location,
5+
FeedProvider,
6+
AppointmentAvailabilityWindow,
7+
AppointmentAvailabilityReport,
8+
LocationFeedConcordance,
9+
)
10+
from api.models import ApiLog
11+
import json
12+
import pathlib
13+
import pytest
14+
15+
tests_dir = pathlib.Path(__file__).parent / "test-data" / "submitAvailabilityReport"
16+
17+
18+
@pytest.mark.django_db
19+
def test_submit_availability_report_api_bad_token(client):
20+
response = client.post("/api/submitAvailabilityReport")
21+
assert response.json() == {"error": "Authorization header must start with 'Bearer'"}
22+
assert response.status_code == 403
23+
last_log = ApiLog.objects.order_by("-id")[0]
24+
assert {
25+
"method": "POST",
26+
"path": "/api/submitAvailabilityReport",
27+
"query_string": "",
28+
"remote_ip": "127.0.0.1",
29+
"response_status": 403,
30+
"created_report_id": None,
31+
}.items() <= last_log.__dict__.items()
32+
33+
34+
@pytest.mark.django_db
35+
def test_submit_report_api_invalid_json(client):
36+
response = client.post(
37+
"/api/submitAvailabilityReport",
38+
"This is bad JSON",
39+
content_type="text/plain",
40+
HTTP_AUTHORIZATION="Bearer {}".format(SCRAPER_API_KEY),
41+
)
42+
assert response.status_code == 400
43+
assert response.json()["error"] == "Expecting value: line 1 column 1 (char 0)"
44+
45+
46+
@pytest.mark.django_db
47+
@pytest.mark.parametrize("json_path", tests_dir.glob("*.json"))
48+
def test_submit_report_api_example(client, json_path):
49+
fixture = json.load(json_path.open())
50+
assert Report.objects.count() == 0
51+
# Ensure location exists
52+
location, _ = Location.objects.get_or_create(
53+
public_id=fixture["location"],
54+
defaults={
55+
"latitude": 0,
56+
"longitude": 0,
57+
"location_type_id": 1,
58+
"state_id": 1,
59+
"county_id": 1,
60+
},
61+
)
62+
# Ensure feed provider exists
63+
provider, _ = FeedProvider.objects.get_or_create(
64+
name="Test feed", slug=fixture["input"]["feed_update"]["feed_provider"]
65+
)
66+
# Create concordance
67+
LocationFeedConcordance.objects.create(
68+
feed_provider=provider,
69+
location=location,
70+
provider_id=fixture["input"]["location"],
71+
)
72+
73+
response = client.post(
74+
"/api/submitAvailabilityReport",
75+
fixture["input"],
76+
content_type="application/json",
77+
HTTP_AUTHORIZATION="Bearer {}".format(SCRAPER_API_KEY),
78+
)
79+
assert response.status_code == fixture["expected_status"]
80+
# Load new report from DB and check it
81+
report = AppointmentAvailabilityReport.objects.order_by("-id")[0]
82+
expected_field_values = AppointmentAvailabilityReport.objects.filter(
83+
pk=report.pk
84+
).values(*list(fixture["expected_fields"].keys()))[0]
85+
assert expected_field_values == fixture["expected_fields"]
86+
87+
# Check the windows
88+
for window, expected_window in zip(
89+
report.windows.all(), fixture["expected_windows"]
90+
):
91+
expected_field_values = AppointmentAvailabilityWindow.objects.filter(
92+
pk=window.pk
93+
).values(*list(expected_window["expected_fields"].keys()))[0]
94+
assert expected_field_values == expected_window["expected_fields"]
95+
96+
actual_tags = [tag.slug for tag in window.additional_restrictions.all()]
97+
assert actual_tags == expected_window["expected_additional_restrictions"]

0 commit comments

Comments
 (0)