diff --git a/README.md b/README.md index 02caa8d7..0f7f694b 100644 --- a/README.md +++ b/README.md @@ -12,37 +12,95 @@ ## Endpoints -All requests must be made to the base url: ``https://coronavirus-tracker-api.herokuapp.com`` (e.g: https://coronavirus-tracker-api.herokuapp.com/all). You can try them out in your browser to further inspect responses. +All requests must be made to the base url: ``https://coronavirus-tracker-api.herokuapp.com/v2/`` (e.g: https://coronavirus-tracker-api.herokuapp.com/v2/locations). You can try them out in your browser to further inspect responses. -Getting confirmed cases, deaths, and recoveries: +### Getting latest amount of total confirmed cases, deaths, and recoveries. ```http -GET /all +GET /v2/latest ``` ```json -{ "latest": { ... }, "confirmed": { ... }, "deaths": { ... }, "recovered": { ... } } +{ + "latest": { + "confirmed": 197146, + "deaths": 7905, + "recovered": 80840 + } +} ``` -Getting just confirmed: +### Getting all locations. ```http -GET /confirmed +GET /v2/locations ``` ```json { - "latest": 42767, - "locations": [ ... ], - "last_updated": "2020-03-07T18:08:58.432242Z", - "source": "https://github.com/ExpDev07/coronavirus-tracker-api" + "locations": [ + { + "id": 0, + "country": "Thailand", + "country_code": "TH", + "province": "", + "coordinates": { + "latitude": "15", + "longitude": "101" + }, + "latest": { + "confirmed": 177, + "deaths": 1, + "recovered": 41 + } + }, + { + "id": 39, + "country": "Norway", + "country_code": "NO", + "province": "", + "coordinates": { + "latitude": "60.472", + "longitude": "8.4689" + }, + "latest": { + "confirmed": 1463, + "deaths": 3, + "recovered": 1 + } + } + ] } ``` -Getting just deaths: +Additionally, you can also filter by country ([alpha-2 country_code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)). ```http -GET /deaths +GET /v2/locations?country_code=US ``` -Getting just recoveries: +### Getting a specific location (includes timeline). ```http -GET /recovered +GET /v2/locations/:id +``` +```json +{ + "location": { + "id": 39, + "country": "Norway", + "country_code": "NO", + "province": "", + "coordinates": { }, + "latest": { }, + "timelines": { + "confirmed": { + "latest": 1463, + "timeline": { + "2020-03-16T00:00:00Z": 1333, + "2020-03-17T00:00:00Z": 1463 + } + }, + "deaths": { }, + "recovered": { } + } + } + } +} ``` ## Data @@ -112,5 +170,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d ## License -The data is available to the public strictly for educational and academic research purposes. Please link to this repo somewhere in your project if you can (not required) :). +The data is available to the public strictly for educational and academic research purposes. Please link to this repo somewhere in your project :). diff --git a/app/__init__.py b/app/__init__.py index dc2a691e..6414cb3e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,5 @@ from flask import Flask from flask_cors import CORS -from . import settings def create_app(): """ @@ -12,7 +11,7 @@ def create_app(): CORS(app) # Set app config from settings. - app.config.from_pyfile('settings.py'); + app.config.from_pyfile('config/settings.py'); with app.app_context(): # Import routes. diff --git a/app/settings.py b/app/config/settings.py similarity index 100% rename from app/settings.py rename to app/config/settings.py diff --git a/app/coordinates.py b/app/coordinates.py new file mode 100644 index 00000000..0243e637 --- /dev/null +++ b/app/coordinates.py @@ -0,0 +1,20 @@ +class Coordinates: + """ + A position on earth using decimal coordinates (latitude and longitude). + """ + + def __init__(self, latitude, longitude): + self.latitude = latitude + self.longitude = longitude + + def serialize(self): + """ + Serializes the coordinates into a dict. + """ + return { + 'latitude' : self.latitude, + 'longitude': self.longitude + } + + def __str__(self): + return 'lat: %s, long: %s' % (self.latitude, self.longitude) \ No newline at end of file diff --git a/app/data/__init__.py b/app/data/__init__.py deleted file mode 100644 index c3d5ffa1..00000000 --- a/app/data/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -import requests -import csv -from datetime import datetime -from cachetools import cached, TTLCache -from ..utils import countrycodes, date as date_util - -""" -Base URL for fetching data. -""" -base_url = 'https://raw.githubusercontent.com/CSSEGISandData/2019-nCoV/master/csse_covid_19_data/csse_covid_19_time_series/time_series_19-covid-%s.csv'; - -@cached(cache=TTLCache(maxsize=1024, ttl=3600)) -def get_data(category): - """ - Retrieves the data for the provided type. The data is cached for 1 hour. - """ - - # Adhere to category naming standard. - category = category.lower().capitalize(); - - # Request the data - request = requests.get(base_url % category) - text = request.text - - # Parse the CSV. - data = list(csv.DictReader(text.splitlines())) - - # The normalized locations. - locations = [] - - for item in data: - # Filter out all the dates. - dates = dict(filter(lambda element: date_util.is_date(element[0]), item.items())) - - # Make location history from dates. - history = { date: int(amount or 0) for date, amount in dates.items() }; - - # Country for this location. - country = item['Country/Region'] - - # Latest data insert value. - latest = list(history.values())[-1]; - - # Normalize the item and append to locations. - locations.append({ - # General info. - 'country': country, - 'country_code': countrycodes.country_code(country), - 'province': item['Province/State'], - - # Coordinates. - 'coordinates': { - 'lat': item['Lat'], - 'long': item['Long'], - }, - - # History. - 'history': history, - - # Latest statistic. - 'latest': int(latest or 0), - }) - - # Latest total. - latest = sum(map(lambda location: location['latest'], locations)) - - # Return the final data. - return { - 'locations': locations, - 'latest': latest, - 'last_updated': datetime.utcnow().isoformat() + 'Z', - 'source': 'https://github.com/ExpDev07/coronavirus-tracker-api', - } diff --git a/app/location.py b/app/location.py new file mode 100644 index 00000000..e348c697 --- /dev/null +++ b/app/location.py @@ -0,0 +1,49 @@ +from .coordinates import Coordinates +from .utils import countrycodes + +class Location: + """ + A location in the world affected by the coronavirus. + """ + + def __init__(self, id, country, province, coordinates, confirmed, deaths, recovered): + # General info. + self.id = id + self.country = country.strip() + self.province = province.strip() + self.coordinates = coordinates + + # Data. + self.confirmed = confirmed + self.deaths = deaths + self.recovered = recovered + + + @property + def country_code(self): + """ + Gets the alpha-2 code represention of the country. Returns 'XX' if none is found. + """ + return (countrycodes.country_code(self.country) or countrycodes.default_code).upper() + + def serialize(self): + """ + Serializes the location into a dict. + """ + return { + # General info. + 'id' : self.id, + 'country' : self.country, + 'province' : self.province, + 'country_code': self.country_code, + + # Coordinates. + 'coordinates': self.coordinates.serialize(), + + # Latest data. + 'latest': { + 'confirmed': self.confirmed.latest, + 'deaths' : self.deaths.latest, + 'recovered': self.recovered.latest + } + } \ No newline at end of file diff --git a/app/models/location.py b/app/models/location.py deleted file mode 100644 index 5f202b3c..00000000 --- a/app/models/location.py +++ /dev/null @@ -1,4 +0,0 @@ -class Location(): - """ - A location in the world affected by the coronavirus. - """ \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 0e2e3c2c..b2bc5b73 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,4 +1,5 @@ -from . import confirmed -from . import deaths -from . import recovered -from . import all \ No newline at end of file +# API version 1. +from .v1 import confirmed, deaths, recovered, all + +# API version 2. +from .v2 import locations, latest \ No newline at end of file diff --git a/app/routes/all.py b/app/routes/v1/all.py similarity index 72% rename from app/routes/all.py rename to app/routes/v1/all.py index f80e289f..735b4d10 100644 --- a/app/routes/all.py +++ b/app/routes/v1/all.py @@ -1,13 +1,13 @@ from flask import jsonify from flask import current_app as app -from ..data import get_data +from ...services.location.jhu import get_category @app.route('/all') def all(): # Get all the categories. - confirmed = get_data('confirmed') - deaths = get_data('deaths') - recovered = get_data('recovered') + confirmed = get_category('confirmed') + deaths = get_category('deaths') + recovered = get_category('recovered') return jsonify({ # Data. diff --git a/app/routes/confirmed.py b/app/routes/v1/confirmed.py similarity index 52% rename from app/routes/confirmed.py rename to app/routes/v1/confirmed.py index dfa3346d..52fcf7e0 100644 --- a/app/routes/confirmed.py +++ b/app/routes/v1/confirmed.py @@ -1,7 +1,7 @@ from flask import jsonify from flask import current_app as app -from ..data import get_data +from ...services.location.jhu import get_category @app.route('/confirmed') def confirmed(): - return jsonify(get_data('confirmed')) \ No newline at end of file + return jsonify(get_category('confirmed')) \ No newline at end of file diff --git a/app/routes/deaths.py b/app/routes/v1/deaths.py similarity index 52% rename from app/routes/deaths.py rename to app/routes/v1/deaths.py index 61ef7546..76913b6d 100644 --- a/app/routes/deaths.py +++ b/app/routes/v1/deaths.py @@ -1,7 +1,7 @@ from flask import jsonify from flask import current_app as app -from ..data import get_data +from ...services.location.jhu import get_category @app.route('/deaths') def deaths(): - return jsonify(get_data('deaths')) \ No newline at end of file + return jsonify(get_category('deaths')) \ No newline at end of file diff --git a/app/routes/recovered.py b/app/routes/v1/recovered.py similarity index 52% rename from app/routes/recovered.py rename to app/routes/v1/recovered.py index 1abdef26..ded8eb7c 100644 --- a/app/routes/recovered.py +++ b/app/routes/v1/recovered.py @@ -1,7 +1,7 @@ from flask import jsonify from flask import current_app as app -from ..data import get_data +from ...services.location.jhu import get_category @app.route('/recovered') def recovered(): - return jsonify(get_data('recovered')) \ No newline at end of file + return jsonify(get_category('recovered')) \ No newline at end of file diff --git a/app/routes/v2/latest.py b/app/routes/v2/latest.py new file mode 100644 index 00000000..0f3ba265 --- /dev/null +++ b/app/routes/v2/latest.py @@ -0,0 +1,18 @@ +from flask import jsonify, current_app as app +from ...services import jhu + +@app.route('/v2/latest') +def latest(): + # Get the serialized version of all the locations. + locations = [ location.serialize() for location in jhu.get_all() ] + + # All the latest information. + latest = list(map(lambda location: location['latest'], locations)) + + return jsonify({ + 'latest': { + 'confirmed': sum(map(lambda latest: latest['confirmed'], latest)), + 'deaths' : sum(map(lambda latest: latest['deaths'], latest)), + 'recovered': sum(map(lambda latest: latest['recovered'], latest)), + } + }) \ No newline at end of file diff --git a/app/routes/v2/locations.py b/app/routes/v2/locations.py new file mode 100644 index 00000000..eb50e251 --- /dev/null +++ b/app/routes/v2/locations.py @@ -0,0 +1,40 @@ +from flask import jsonify, request, current_app as app +from ...services import jhu + +@app.route('/v2/locations') +def locations(): + # Query parameters. + country_code = request.args.get('country_code', type=str) + + # Retrieve all the locations. + locations = jhu.get_all() + + # Filtering my country code if provided. + if not country_code is None: + locations = list(filter(lambda location: location.country_code == country_code.upper(), locations)) + + # Serialize each location and return. + return jsonify({ + 'locations': [ + location.serialize() for location in locations + ] + }) + +@app.route('/v2/locations/') +def location(id): + # Retrieve location with the provided id. + location = jhu.get(id) + + # Get all the timelines. + timelines = { + 'confirmed': location.confirmed.serialize(), + 'deaths' : location.deaths.serialize(), + 'recovered': location.recovered.serialize(), + } + + # Serialize the location, add timelines, and then return. + return jsonify({ + 'location': { + **jhu.get(id).serialize(), **{ 'timelines': timelines } + } + }) \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 00000000..0c5b206c --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,4 @@ +from .location.jhu import JhuLocationService + +# Instances of the services. +jhu = JhuLocationService() \ No newline at end of file diff --git a/app/services/location/__init__.py b/app/services/location/__init__.py new file mode 100644 index 00000000..3338b3d3 --- /dev/null +++ b/app/services/location/__init__.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod + +class LocationService(ABC): + """ + Service for retrieving locations. + """ + + @abstractmethod + def get_all(self): + """ + Gets and returns all of the locations. + + :returns: The locations. + :rtype: List[Location] + """ + raise NotImplementedError + + @abstractmethod + def get(self, id): + """ + Gets and returns location with the provided id. + + :returns: The location. + :rtype: Location + """ + raise NotImplementedError \ No newline at end of file diff --git a/app/services/location/jhu.py b/app/services/location/jhu.py new file mode 100644 index 00000000..621c56ca --- /dev/null +++ b/app/services/location/jhu.py @@ -0,0 +1,137 @@ +from . import LocationService +from ...location import Location +from ...coordinates import Coordinates +from ...timeline import Timeline + +class JhuLocationService(LocationService): + """ + Service for retrieving locations from Johns Hopkins CSSE (https://github.com/CSSEGISandData/COVID-19). + """ + + def get_all(self): + # Get the locations. + return get_locations() + + def get(self, id): + # Get location at the index equal to provided id. + return self.get_all()[id] + +import requests +import csv +from datetime import datetime +from cachetools import cached, TTLCache +from ...utils import countrycodes, date as date_util + +""" +Base URL for fetching category. +""" +base_url = 'https://raw.githubusercontent.com/CSSEGISandData/2019-nCoV/master/csse_covid_19_data/csse_covid_19_time_series/time_series_19-covid-%s.csv'; + +@cached(cache=TTLCache(maxsize=1024, ttl=3600)) +def get_category(category): + """ + Retrieves the data for the provided category. The data is cached for 1 hour. + + :returns: The data for category. + :rtype: dict + """ + + # Adhere to category naming standard. + category = category.lower().capitalize(); + + # Request the data + request = requests.get(base_url % category) + text = request.text + + # Parse the CSV. + data = list(csv.DictReader(text.splitlines())) + + # The normalized locations. + locations = [] + + for item in data: + # Filter out all the dates. + dates = dict(filter(lambda element: date_util.is_date(element[0]), item.items())) + + # Make location history from dates. + history = { date: int(amount or 0) for date, amount in dates.items() }; + + # Country for this location. + country = item['Country/Region'] + + # Latest data insert value. + latest = list(history.values())[-1]; + + # Normalize the item and append to locations. + locations.append({ + # General info. + 'country': country, + 'country_code': countrycodes.country_code(country), + 'province': item['Province/State'], + + # Coordinates. + 'coordinates': { + 'lat': item['Lat'], + 'long': item['Long'], + }, + + # History. + 'history': history, + + # Latest statistic. + 'latest': int(latest or 0), + }) + + # Latest total. + latest = sum(map(lambda location: location['latest'], locations)) + + # Return the final data. + return { + 'locations': locations, + 'latest': latest, + 'last_updated': datetime.utcnow().isoformat() + 'Z', + 'source': 'https://github.com/ExpDev07/coronavirus-tracker-api', + } + +@cached(cache=TTLCache(maxsize=1024, ttl=3600)) +def get_locations(): + """ + Retrieves the locations from the categories. The locations are cached for 1 hour. + + :returns: The locations. + :rtype: List[Location] + """ + # Get all of the data categories locations. + confirmed = get_category('confirmed')['locations'] + deaths = get_category('deaths')['locations'] + recovered = get_category('recovered')['locations'] + + # Final locations to return. + locations = [] + + # Go through locations. + for index, location in enumerate(confirmed): + # Get the timelines. + timelines = { + 'confirmed' : confirmed[index]['history'], + 'deaths' : deaths[index]['history'], + 'recovered' : recovered[index]['history'], + } + + # Grab coordinates. + coordinates = location['coordinates'] + + # Create location and append. + locations.append(Location( + # General info. + index, location['country'], location['province'], Coordinates(coordinates['lat'], coordinates['long']), + + # Timelines (parse dates as ISO). + Timeline({ datetime.strptime(date, '%m/%d/%y').isoformat() + 'Z': amount for date, amount in timelines['confirmed'].items() }), + Timeline({ datetime.strptime(date, '%m/%d/%y').isoformat() + 'Z': amount for date, amount in timelines['deaths'].items() }), + Timeline({ datetime.strptime(date, '%m/%d/%y').isoformat() + 'Z': amount for date, amount in timelines['recovered'].items() }) + )) + + # Finally, return the locations. + return locations + \ No newline at end of file diff --git a/app/timeline.py b/app/timeline.py new file mode 100644 index 00000000..48af1ac5 --- /dev/null +++ b/app/timeline.py @@ -0,0 +1,33 @@ +from datetime import datetime +from collections import OrderedDict + +class Timeline: + """ + Timeline with history of data. + """ + + def __init__(self, history = {}): + self.__timeline = history + + @property + def timeline(self): + """ + Gets the history sorted by date (key). + """ + return OrderedDict(sorted(self.__timeline.items())) + + @property + def latest(self): + """ + Gets the latest available history value. + """ + return list(self.timeline.values())[-1] or 0 + + def serialize(self): + """ + Serializes the data into a dict. + """ + return { + 'latest' : self.latest, + 'timeline': self.timeline + } \ No newline at end of file diff --git a/app/utils/countrycodes.py b/app/utils/countrycodes.py index 07d067d8..4215d62c 100644 --- a/app/utils/countrycodes.py +++ b/app/utils/countrycodes.py @@ -346,8 +346,7 @@ def country_code(country): """ - Return two letter country code (Alpha-2) according to - https://en.wikipedia.org/wiki/ISO_3166-1 + Return two letter country code (Alpha-2) according to https://en.wikipedia.org/wiki/ISO_3166-1 Defaults to "XX". """ if country in is_3166_1: