From 6b6ac3024908cd8f21138fb0a1941e2b9c7b0eee Mon Sep 17 00:00:00 2001 From: Nicholas Schiefer Date: Wed, 3 Mar 2021 23:15:01 -0500 Subject: [PATCH] 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. --- docs/api.md | 59 ++++++ vaccinate/api/migrations/0001_initial.py | 15 +- vaccinate/api/models.py | 8 + .../submitAvailabilityReport/001.json | 45 +++++ vaccinate/api/test_availability_report.py | 97 ++++++++++ vaccinate/api/views.py | 143 +++++++++++++- vaccinate/config/settings.py | 3 + vaccinate/config/urls.py | 1 + vaccinate/core/admin.py | 12 ++ .../migrations/0031_auto_20210303_2019.py | 22 --- .../migrations/0031_availability_reports.py | 179 ++++++++++++++++++ vaccinate/core/models.py | 103 +++++++++- 12 files changed, 656 insertions(+), 31 deletions(-) create mode 100644 vaccinate/api/test-data/submitAvailabilityReport/001.json create mode 100644 vaccinate/api/test_availability_report.py delete mode 100644 vaccinate/core/migrations/0031_auto_20210303_2019.py create mode 100644 vaccinate/core/migrations/0031_availability_reports.py diff --git a/docs/api.md b/docs/api.md index 74c3583..899d6d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -83,3 +83,62 @@ A tool for trying out this API is available at https://vaccinateca-preview.herok Anything submitted using that tool will have `is_test_data` set to True in the database. You can view test reports here: https://vaccinateca-preview.herokuapp.com/admin/core/report/?is_test_data__exact=1 + +## api/submitAvailabilityReport + +This endpoint records an availability report for a location, which generally includes a list of known appointment windows. +It will usually be used by an automated script. + +This API endpoint is called with an HTTP post, with JSON in teh POST body. +The `SCRAPER_API_KEY` should be sent in the header as `Authorization: Bearer `. + +The JSON document must have the following keys: +- `feed_update`: an object with three keys: `uuid`, `github_url`, and `feed_provider`. + 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). + The `uuid` should be generated by the client (e.g., using Python's `uuid.uuid4()` method). + The `github_url` is a URL to our repository on GitHub where the raw data can be inspected. + The `feed_provider` is a slug that uniquely refers to this particular data source (e.g., `curative` for Curative's JSON feed). + Provider slugs are available in the `Feed provider` table. +- `location`. This is a unique identifier to the location _used by this feed provider_. + It is not the `public_id` of the location. + A pre-submitted concordance is used to map this to a location in our database. +- `availability_windows` is a list of objects, each of which has the fields `starts_at`, `ends_at`, `slots`, and `additional_restrictions`. + The `starts_at` and `ends_at` fields are timestamps that indicate the bounds of this window. + The `slots` field indicates the number of currently available slots in this window. + The `additional_restrictions` field is a list of availability tags, using their slugs (see above). + +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) +used to inform the availability report _for this location_. + +For example: +```json +{ + "feed_update": { + "uuid": "02d63a35-5dbc-4ac8-affb-14603bf6eb2e", + "github_url": "https://example.com", + "feed_provider": "test_provider" + }, + "location": "116", + "availability_windows": [ + { + "starts_at": "2021-02-28T10:00:00Z", + "ends_at": "2021-02-28T11:00:00Z", + "slots": 25, + "additional_restrictions": [] + }, + { + "starts_at": "2021-02-28T11:00:00Z", + "ends_at": "2021-02-28T12:00:00Z", + "slots": 18, + "additional_restrictions": [ + "vaccinating_65_plus" + ] + } + ] +} +``` + +If the request was successful, it returns a 201 Created HTTP response. + +A common reason for an unsuccessful request is the lack of concordance between the provider's location ID and our known +locations. In general, an automated process should find and flag new locations in each scrape before publshing availability. diff --git a/vaccinate/api/migrations/0001_initial.py b/vaccinate/api/migrations/0001_initial.py index f41709f..b04b65c 100644 --- a/vaccinate/api/migrations/0001_initial.py +++ b/vaccinate/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.7 on 2021-03-03 20:19 +# Generated by Django 3.1.7 on 2021-03-04 05:34 import core.fields from django.db import migrations, models @@ -11,7 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("core", "0031_auto_20210303_2019"), + ("core", "0031_auto_20210304_0534"), ] operations = [ @@ -88,6 +88,17 @@ class Migration(migrations.Migration): blank=True, help_text="Response body if it was JSON", null=True ), ), + ( + "created_availability_report", + models.ForeignKey( + blank=True, + help_text="Availability report that was created by this API call, if any", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_by_api_logs", + to="core.appointmentavailabilityreport", + ), + ), ( "created_report", models.ForeignKey( diff --git a/vaccinate/api/models.py b/vaccinate/api/models.py index b736fc4..438bac1 100644 --- a/vaccinate/api/models.py +++ b/vaccinate/api/models.py @@ -37,6 +37,14 @@ class ApiLog(models.Model): on_delete=models.SET_NULL, help_text="Report that was created by this API call, if any", ) + created_availability_report = models.ForeignKey( + "core.AppointmentAvailabilityReport", + null=True, + blank=True, + related_name="created_by_api_logs", + on_delete=models.SET_NULL, + help_text="Availability report that was created by this API call, if any", + ) class Meta: db_table = "api_log" diff --git a/vaccinate/api/test-data/submitAvailabilityReport/001.json b/vaccinate/api/test-data/submitAvailabilityReport/001.json new file mode 100644 index 0000000..98722c4 --- /dev/null +++ b/vaccinate/api/test-data/submitAvailabilityReport/001.json @@ -0,0 +1,45 @@ +{ + "location": "recaQlVkkI1rNarvx", + "input": { + "feed_update": { + "uuid": "02d63a35-5dbc-4ac8-affb-14603bf6eb2e", + "github_url": "https://example.com", + "feed_provider": "test_provider" + }, + "location": "116", + "availability_windows": [ + { + "starts_at": "2021-02-28T10:00:00Z", + "ends_at": "2021-02-28T11:00:00Z", + "slots": 25, + "additional_restrictions": [] + }, + { + "starts_at": "2021-02-28T11:00:00Z", + "ends_at": "2021-02-28T12:00:00Z", + "slots": 18, + "additional_restrictions": ["vaccinating_65_plus"] + } + ] + }, + "expected_status": 201, + "expected_fields": { + "location__public_id": "recaQlVkkI1rNarvx" + }, + "expected_windows": [ + { + "expected_fields": + { + "slots": 25 + }, + "expected_additional_restrictions": [] + }, + { + "expected_fields": + { + "slots": 18 + }, + "expected_additional_restrictions": ["vaccinating_65_plus"] + } + ] +} \ No newline at end of file diff --git a/vaccinate/api/test_availability_report.py b/vaccinate/api/test_availability_report.py new file mode 100644 index 0000000..e4cfa75 --- /dev/null +++ b/vaccinate/api/test_availability_report.py @@ -0,0 +1,97 @@ +from config.settings import SCRAPER_API_KEY +from core.models import ( + Report, + Location, + FeedProvider, + AppointmentAvailabilityWindow, + AppointmentAvailabilityReport, + LocationFeedConcordance, +) +from api.models import ApiLog +import json +import pathlib +import pytest + +tests_dir = pathlib.Path(__file__).parent / "test-data" / "submitAvailabilityReport" + + +@pytest.mark.django_db +def test_submit_availability_report_api_bad_token(client): + response = client.post("/api/submitAvailabilityReport") + assert response.json() == {"error": "Authorization header must start with 'Bearer'"} + assert response.status_code == 403 + last_log = ApiLog.objects.order_by("-id")[0] + assert { + "method": "POST", + "path": "/api/submitAvailabilityReport", + "query_string": "", + "remote_ip": "127.0.0.1", + "response_status": 403, + "created_report_id": None, + }.items() <= last_log.__dict__.items() + + +@pytest.mark.django_db +def test_submit_report_api_invalid_json(client): + response = client.post( + "/api/submitAvailabilityReport", + "This is bad JSON", + content_type="text/plain", + HTTP_AUTHORIZATION="Bearer {}".format(SCRAPER_API_KEY), + ) + assert response.status_code == 400 + assert response.json()["error"] == "Expecting value: line 1 column 1 (char 0)" + + +@pytest.mark.django_db +@pytest.mark.parametrize("json_path", tests_dir.glob("*.json")) +def test_submit_report_api_example(client, json_path): + fixture = json.load(json_path.open()) + assert Report.objects.count() == 0 + # Ensure location exists + location, _ = Location.objects.get_or_create( + public_id=fixture["location"], + defaults={ + "latitude": 0, + "longitude": 0, + "location_type_id": 1, + "state_id": 1, + "county_id": 1, + }, + ) + # Ensure feed provider exists + provider, _ = FeedProvider.objects.get_or_create( + name="Test feed", slug=fixture["input"]["feed_update"]["feed_provider"] + ) + # Create concordance + LocationFeedConcordance.objects.create( + feed_provider=provider, + location=location, + provider_id=fixture["input"]["location"], + ) + + response = client.post( + "/api/submitAvailabilityReport", + fixture["input"], + content_type="application/json", + HTTP_AUTHORIZATION="Bearer {}".format(SCRAPER_API_KEY), + ) + assert response.status_code == fixture["expected_status"] + # Load new report from DB and check it + report = AppointmentAvailabilityReport.objects.order_by("-id")[0] + expected_field_values = AppointmentAvailabilityReport.objects.filter( + pk=report.pk + ).values(*list(fixture["expected_fields"].keys()))[0] + assert expected_field_values == fixture["expected_fields"] + + # Check the windows + for window, expected_window in zip( + report.windows.all(), fixture["expected_windows"] + ): + expected_field_values = AppointmentAvailabilityWindow.objects.filter( + pk=window.pk + ).values(*list(expected_window["expected_fields"].keys()))[0] + assert expected_field_values == expected_window["expected_fields"] + + actual_tags = [tag.slug for tag in window.additional_restrictions.all()] + assert actual_tags == expected_window["expected_additional_restrictions"] diff --git a/vaccinate/api/views.py b/vaccinate/api/views.py index 4fa4fbe..6304831 100644 --- a/vaccinate/api/views.py +++ b/vaccinate/api/views.py @@ -1,11 +1,29 @@ +from datetime import datetime +from uuid import UUID + +from django.views.decorators.http import require_http_methods + from auth0login.auth0_utils import decode_and_verify_jwt from dateutil import parser -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from pydantic import BaseModel, validator, ValidationError, Field from typing import List, Optional -from core.models import AppointmentTag, AvailabilityTag, Location, Report, Reporter + +from config.settings import SCRAPER_API_KEY +from core.models import ( + AppointmentTag, + AvailabilityTag, + Location, + Report, + Reporter, + FeedProvider, + FeedUpdate, + AppointmentAvailabilityReport, + AppointmentAvailabilityWindow, + LocationFeedConcordance, +) from core.import_utils import derive_appointment_tag, resolve_availability_tags from .utils import log_api_requests import json @@ -129,3 +147,124 @@ def submit_report_debug(request): "api/submit_report_debug.html", {"jwt": request.session["jwt"] if "jwt" in request.session else ""}, ) + + +class FeedUpdateValidator(BaseModel): + uuid: UUID = Field() + feed_provider: str = Field() + github_url: str = Field() + + @validator("feed_provider") + def feed_provider_must_exist(cls, v): + try: + return FeedProvider.objects.get(slug=v) + except FeedProvider.DoesNotExist: + raise ValueError("Feed provider '{}' does not exist".format(v)) + + +class AppointmentAvailabilityWindowValidator(BaseModel): + starts_at: datetime = Field() + ends_at: datetime = Field() + slots: int = Field() + additional_restrictions: List[str] = Field() + + @validator("additional_restrictions") + def additional_restrictions_are_availability_tags(cls, ls): + tags = [] + for tag in ls: + try: + tags.append(AvailabilityTag.objects.get(slug=tag)) + except AvailabilityTag.DoesNotExist: + raise ValueError( + "Availability tag with slug '{}' does not exist".format(tag) + ) + return tags + + +class AppointmentAvailabilityReportValidator(BaseModel): + location: str = Field() + feed_json: Optional[str] = Field() + feed_update: FeedUpdateValidator = Field() + availability_windows: List[AppointmentAvailabilityWindowValidator] = Field() + + appointment_details: Optional[str] = Field( + alias="Appointment scheduling instructions" + ) + + +@csrf_exempt +@log_api_requests +@require_http_methods(["POST"]) +def submit_availability_report(request, on_request_logged): + authorization = request.META.get("HTTP_AUTHORIZATION") or "" + if not authorization.startswith("Bearer "): + return JsonResponse( + {"error": "Authorization header must start with 'Bearer'"}, status=403 + ) + + # Check that the API key is correct + api_key = authorization.split("Bearer ")[1] + if api_key != SCRAPER_API_KEY: + return JsonResponse({"error": "Invalid API key"}, status=403) + + try: + post_data = json.loads(request.body.decode("utf-8")) + except ValueError as e: + return JsonResponse({"error": str(e)}, status=400) + try: + data = AppointmentAvailabilityReportValidator(**post_data).dict() + except ValidationError as e: + return JsonResponse({"error": e.errors()}, status=400) + + # Check if we already know about this feed update; create it if we don't + feed_update, _ = FeedUpdate.objects.update_or_create( + uuid=data["feed_update"]["uuid"], + feed_provider=data["feed_update"]["feed_provider"], + github_url=data["feed_update"]["github_url"], + ) + + try: + location = LocationFeedConcordance.objects.get( + feed_provider=data["feed_update"]["feed_provider"], + provider_id=data["location"], + ).location + except LocationFeedConcordance.DoesNotExist: + return JsonResponse( + {"error": "Location is not matched to any known location."}, status=400 + ) + + # Create the availability report + report_kwargs = dict( + is_test_data=bool(request.POST.get("test")), + location=location, + feed_update=feed_update, + feed_json=data["feed_json"], + ) + if bool(request.GET.get("test")) and request.GET.get("fake_timestamp"): + fake_timestamp = parser.parse(request.GET["fake_timestamp"]) + if fake_timestamp.tzinfo is None: + # Assume this is UTC + fake_timestamp = pytz.UTC.localize(fake_timestamp) + report_kwargs["created_at"] = fake_timestamp + + report = AppointmentAvailabilityReport.objects.create(**report_kwargs) + + for window_data in data["availability_windows"]: + window = AppointmentAvailabilityWindow.objects.create( + availability_report=report, + starts_at=window_data["starts_at"], + ends_at=window_data["ends_at"], + slots=window_data["slots"], + ) + for tag in window_data["additional_restrictions"]: + window.additional_restrictions.add(tag) + + # Refresh Report from DB to get .public_id + report.refresh_from_db() + + def log_created_report(log): + log.created_availability_report = report + log.save() + + on_request_logged(log_created_report) + return HttpResponse(status=201) # 201 Created diff --git a/vaccinate/config/settings.py b/vaccinate/config/settings.py index 6b573e2..88bda62 100644 --- a/vaccinate/config/settings.py +++ b/vaccinate/config/settings.py @@ -25,6 +25,9 @@ send_default_pii=True, ) +# Scraper API key +SCRAPER_API_KEY = os.environ["SCRAPER_API_KEY"] + # Auth0 SOCIAL_AUTH_TRAILING_SLASH = False SOCIAL_AUTH_AUTH0_DOMAIN = "vaccinateca.us.auth0.com" diff --git a/vaccinate/config/urls.py b/vaccinate/config/urls.py index 8146e40..c1e0b3d 100644 --- a/vaccinate/config/urls.py +++ b/vaccinate/config/urls.py @@ -12,6 +12,7 @@ path("logout", logout), path("api/submitReport", api_views.submit_report), path("api/submitReport/debug", api_views.submit_report_debug), + path("api/submitAvailabilityReport", api_views.submit_availability_report), path("", include("django.contrib.auth.urls")), path("", include("social_django.urls")), path( diff --git a/vaccinate/core/admin.py b/vaccinate/core/admin.py index 094db4c..1db7c58 100644 --- a/vaccinate/core/admin.py +++ b/vaccinate/core/admin.py @@ -19,6 +19,8 @@ CallRequestReason, CallRequest, PublishedReport, + FeedProvider, + LocationFeedConcordance, ) # Simple models first @@ -226,3 +228,13 @@ class PublishedReportAdmin(admin.ModelAdmin): "reports", "eva_reports", ) + + +@admin.register(FeedProvider) +class FeedProviderAdmin(admin.ModelAdmin): + list_display = ("name", "slug") + + +@admin.register(LocationFeedConcordance) +class LocationFeedConcordanceAdmin(admin.ModelAdmin): + list_display = ("feed_provider", "location", "provider_id") diff --git a/vaccinate/core/migrations/0031_auto_20210303_2019.py b/vaccinate/core/migrations/0031_auto_20210303_2019.py deleted file mode 100644 index d62c051..0000000 --- a/vaccinate/core/migrations/0031_auto_20210303_2019.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1.7 on 2021-03-03 20:19 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0030_provider_null_fields"), - ] - - operations = [ - migrations.AlterField( - model_name="report", - name="created_at", - field=models.DateTimeField( - default=django.utils.timezone.now, - help_text="the time when the report was submitted. We will interpret this as a validity time", - ), - ), - ] diff --git a/vaccinate/core/migrations/0031_availability_reports.py b/vaccinate/core/migrations/0031_availability_reports.py new file mode 100644 index 0000000..574554c --- /dev/null +++ b/vaccinate/core/migrations/0031_availability_reports.py @@ -0,0 +1,179 @@ +# Generated by Django 3.1.7 on 2021-03-04 05:34 + +import core.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0030_provider_null_fields"), + ] + + operations = [ + migrations.CreateModel( + name="AppointmentAvailabilityReport", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("feed_json", models.JSONField(blank=True, null=True)), + ("is_test_data", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="FeedProvider", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.TextField(unique=True)), + ("slug", models.TextField(unique=True)), + ], + ), + migrations.AlterField( + model_name="callrequest", + name="tip_type", + field=core.fields.CharTextField( + blank=True, + choices=[ + ("eva_report", "Eva report"), + ("scooby_report", "Scooby report"), + ("data_corrections_report", "Data corrections report"), + ("feed_report", "Feed report"), + ], + help_text=" the type of tip that prompted this call request, if any", + max_length=65000, + null=True, + ), + ), + migrations.AlterField( + model_name="report", + name="created_at", + field=models.DateTimeField( + default=django.utils.timezone.now, + help_text="the time when the report was submitted. We will interpret this as a validity time", + ), + ), + migrations.CreateModel( + name="LocationFeedConcordance", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("provider_id", models.TextField()), + ( + "feed_provider", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="location_concordances", + to="core.feedprovider", + ), + ), + ( + "location", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="feed_concordances", + to="core.location", + ), + ), + ], + ), + migrations.CreateModel( + name="FeedUpdate", + fields=[ + ("uuid", models.UUIDField(primary_key=True, serialize=False)), + ( + "github_url", + models.URLField( + verbose_name="GitHub URL where the contents of this feed report can be found" + ), + ), + ( + "feed_provider", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="updates", + to="core.feedprovider", + ), + ), + ], + ), + migrations.CreateModel( + name="AppointmentAvailabilityWindow", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("starts_at", models.DateTimeField()), + ("ends_at", models.DateTimeField()), + ( + "slots", + models.IntegerField( + help_text="the number of appointments available in this window" + ), + ), + ( + "additional_restrictions", + models.ManyToManyField( + db_table="appointment_availability_window_availability_tag", + to="core.AvailabilityTag", + ), + ), + ( + "availability_report", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="windows", + to="core.appointmentavailabilityreport", + ), + ), + ], + ), + migrations.AddField( + model_name="appointmentavailabilityreport", + name="feed_update", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="appointment_availability_reports", + to="core.feedupdate", + ), + ), + migrations.AddField( + model_name="appointmentavailabilityreport", + name="location", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="availability_reports", + to="core.location", + ), + ), + ] diff --git a/vaccinate/core/models.py b/vaccinate/core/models.py index 57ecb7f..630ddfd 100644 --- a/vaccinate/core/models.py +++ b/vaccinate/core/models.py @@ -1,4 +1,5 @@ import datetime + from django.db import models from django.utils import timezone from django.utils import dateformat @@ -444,6 +445,13 @@ class Meta: db_table = "call_request_reason" +class ReportType(models.TextChoices): + EVA = "eva_report", "Eva report" + SCOOBY = "scooby_report", "Scooby report" + DATA_CORRECTIONS = "data_corrections_report", "Data corrections report" + FEED = "feed_report", "Feed report" + + class CallRequest(models.Model): """ A request to make a phone call (i.e., an entry in the call queue). @@ -451,11 +459,6 @@ class CallRequest(models.Model): For example, if a bug in an app has us call a location repeatedly, we have the full record of why those calls were made. """ - class ReportType(models.TextChoices): - EVA = "eva_report", "Eva report" - SCOOBY = "scooby_report", "Scooby report" - DATA_CORRECTIONS = "data_corrections_report", "Data corrections report" - location = models.ForeignKey( Location, related_name="call_requests", on_delete=models.PROTECT ) @@ -563,3 +566,93 @@ def __str__(self): class Meta: db_table = "published_report" + + +class FeedProvider(models.Model): + """ + A provider of feed-based data, such as a JSON API or a scraper for a particular web page. + Used to track the provenance of availability data. + """ + + name = models.TextField(unique=True) + slug = models.TextField(unique=True) + # expand with other metadata as needed + + +class FeedUpdate(models.Model): + """ + A single update produced by a FeedProvider. + For an API consumer, this corresponds to a single ingestion event. + For a scraper, this corresponds to a single page scrape. + + A single update might include many AppointmentAvailabilityReports, generally one per location. + """ + + uuid = models.UUIDField(primary_key=True) + feed_provider = models.ForeignKey( + FeedProvider, related_name="updates", on_delete=models.PROTECT + ) + github_url = models.URLField( + "GitHub URL where the contents of this feed report can be found" + ) + + +class AppointmentAvailabilityReport(models.Model): + """ + A report about vaccine appointment availability at a known location. + A single report might encompass a large number of availability windows, each of which might have special constraints + on who is eligible to use it (e.g., only second Moderna doses). + """ + + location = models.ForeignKey( + Location, related_name="availability_reports", on_delete=models.PROTECT + ) + + feed_update = models.ForeignKey( + FeedUpdate, + related_name="appointment_availability_reports", + on_delete=models.PROTECT, + ) + + created_at = models.DateTimeField(default=timezone.now) + feed_json = models.JSONField(null=True, blank=True) + is_test_data = models.BooleanField(default=False) + + +class AppointmentAvailabilityWindow(models.Model): + """ + A window during which vaccination appointments are currently available. + A window might have additional restrictions (i.e., restrictions beyond the general restrictions for the location), + which are currently the same AvailabilityTags used to capture restrictions about the location. + The restrictions for this window are the union of the restrictions for the location and the additional_restrictions. + """ + + availability_report = models.ForeignKey( + AppointmentAvailabilityReport, related_name="windows", on_delete=models.PROTECT + ) + + starts_at = models.DateTimeField() + ends_at = models.DateTimeField() + + slots = models.IntegerField( + help_text="the number of appointments available in this window" + ) + + additional_restrictions = models.ManyToManyField( + AvailabilityTag, db_table="appointment_availability_window_availability_tag" + ) + + +class LocationFeedConcordance(models.Model): + """ + A known relationship between a location in our database and a location identification scheme used by a FeedProvider. + e.g., a CVS store number + """ + + location = models.ForeignKey( + Location, related_name="feed_concordances", on_delete=models.PROTECT + ) + feed_provider = models.ForeignKey( + FeedProvider, related_name="location_concordances", on_delete=models.PROTECT + ) + provider_id = models.TextField(null=False)