diff --git a/.gitignore b/.gitignore index e69de29..d637e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,4 @@ +**/*.pyc +**/terraform/ + +update-ecs.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..801003f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.10.4-alpine3.15 + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE 1 + +COPY . /app + +RUN apk update \ + && apk add --virtual build-deps gcc musl-dev \ + && apk add --no-cache \ + postgresql-dev \ + libffi-dev \ + py-cffi \ + gettext \ + postgresql-client \ + libpq-dev \ + py3-gunicorn \ + && apk add --no-cache --virtual .build-deps-edge --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing \ + gdal-dev \ + geos-dev \ + proj-dev \ + && pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir virtualenv \ + && virtualenv /env \ + && /env/bin/pip install --no-cache-dir --upgrade pip \ + && /env/bin/pip install -r /app/requirements-dev.txt \ + && rm -rf /root/.cache + +RUN adduser -D -H backoffice_user \ + && mkdir -p /app/log \ + && touch /app/log/shuttleapp.log \ + && chown -R backoffice_user:backoffice_user /app \ + && chmod +x /app/docker/entrypoint.sh + +EXPOSE 8091 + +CMD ["/app/docker/entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c30802 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +SHELL := /bin/bash + +all: build test + +build: + docker-compose build + +up: + docker-compose up -d + +down: + docker-compose down --remove-orphans + +shell: + docker-compose run shuttleapp /env/bin/python /app/manage.py shell + +test: up + docker-compose exec shuttleapp /env/bin/python /app/manage.py test + +migrate: up + docker-compose exec shuttleapp /env/bin/python /app/manage.py migrate + +makemigrations: up + docker-compose exec shuttleapp /env/bin/python /app/manage.py makemigrations + +.PHONY: build up test diff --git a/README.md b/README.md new file mode 100644 index 0000000..28a09a2 --- /dev/null +++ b/README.md @@ -0,0 +1,353 @@ +# Shuttle App + +This service is created to help transportation suppliers to define their own "service area" in a map, giving them flexibility and removing the limitation of ZIP codes or city boundaries. + +This document serves as a technical specification and documentation of the project. + +## Requirements + +- REST API to CRUD Providers. +- REST API to CRUD Service Areas. +- An API to retrieve the service areas covering a specific point defined by its geo-coordinates. + +## Solution Description + +The solution will be implemented in Python, using the Django framework with the library django-rest-framework to build REST APIs. +The application will run inside a Docker container and will be deployed to AWS. +The database will be PostgreSQL and there will be geographical information system tools and libraries used. +There will be a docker-compose file managed with a Makefile, to build the container, run test cases and to provide helpers for development. +There will be a cache implemented using Redis, and heuristics to ensure searches will run fast. + +## Technical Specifications + +The solution will be contained in a package called **providers**. + +### Models + +There are two models, Provider and ServiceArea. This design allows one provider to have zero, one, or many service areas: + +![UML diagram](uml.png?raw=true "UML") + +**Provider** +```sh +currency - string (ISO 4217 format) +email - string +language - string (ISO 639-3 format) +name - string +phone_number - string +timestamp - datetime (auto field) +``` + +**ServiceArea** +```sh +name - string +price - float +polygon - Polygon +provider - Foreign Key(Provider) +``` +The Polygon type is a list of tuples. Each tuple defines a valid point, and the list contains a valid Polygon, meaning that each point connects with the next one, and the last point closes the polygon by connecting to the first one. A valid polygon example is: +```python +[ + [0.0, 0.0], + [0.0, 50.0], + [50.0, 50.0], + [50.0, 0.0], + [0.0, 0.0] +] +``` + +**WorldArea** +```sh +code - string +polygon - Polygon +``` +This table is populated at database creation time with a data migrations. It's intended to be used as a tool for the heuristic implemented for the cache. For more details refer to the cache section. + + +### APIs + +#### **POST /provider** +Creates a Provider in database. + +**Input payload:** +```javascript +{ + 'currency': str, + 'email': str, + 'language': str, + 'name': str, + 'phone_number': str +} +``` + +**Response:** +- 400 and an appropiate message in the case of missing fields or invalid field values. There is validation of the phone number, email, currency and language. +- 201 in the case the provider was successfully created, with the following content: +```javascript +{ + 'currency': str, + 'email': str, + 'id': int, + 'language': str, + 'name': str, + 'phone_number': str, + 'timestamp': str // datetime of creation +} +``` + + +#### **GET /provider** +Lists all the providers in database. + +**Response:** +- 200 and a list of all providers in the system: +```javascript +[ + { + 'currency': str, + 'email': str, + 'id': int, + 'language': str, + 'name': str, + 'phone_number': str, + 'timestamp': str + }, + ... +] +``` + +#### **PUT /provider/:id:** +Updates a Provider in database. + +**Input payload:** +```javascript +{ + 'currency': str, + 'email': str, + 'language': str, + 'name': str, + 'phone_number': str +} +``` + +**Response:** +- 400 and an appropiate message in the case of missing fields or invalid field values. There is validation of the phone number, email, currency and language. +- 200 in the case the provider was successfully updated, with the following content: +```javascript +{ + 'currency': str, + 'email': str, + 'id': int, + 'language': str, + 'name': str, + 'phone_number': str, + 'timestamp': str +} +``` + +#### **GET /provider/:id:** +Retrieves a provider and its service areas from database. + +**Response:** +- 404 in case the provider with the given id doesn't exist in database. +- 200 in case the provider was found, with the following content: +```javascript +{ + 'currency': str, + 'email': str, + 'id': int, + 'language': str, + 'name': str, + 'phone_number': str, + 'timestamp': str +} +``` + +#### **DELETE /provider/:id:** +Deletes a provider in database. + +**Response:** +- 404 in case the provider with the given id doesn't exist in database. +- 204 in case the provider was successfully deleted. + + + + +#### **POST /provider/service-area** +Creates a Service Area in database. + +**Input payload:** +```javascript +{ + 'name': str, + 'price': float, + 'provider': int, + 'polygon': list[list[float]] +} +``` + +**Example:** +```javascript +{ + 'name': 'Bermuda Triangle', + 'price': 40.5, + 'provider': 1, + 'polygon': [[0.0, 0.0], [25.0, 50.0], [50.0, 0.0], [0.0, 0.0]] +} +``` +**Response:** +- 400 and an appropiate message in the case of missing fields or invalid field values. There is validation of the provider id and polygon. +- 201 in the case the service area was successfully created. + + +#### **GET /provider/service-area** +Lists all the service area objects in database. + +**Response:** +- 200 and a list of all service area objects. +```javascript +[ + { + 'name': str, + 'price': float, + 'provider': int, + 'polygon': list[list[float]] + }, + ... +] +``` + +#### **PUT /provider/service-area/:id:** +Updates a Service Area in database. + +**Input payload:** +```javascript +{ + 'name': str, + 'price': float, + 'provider': int, + 'polygon': list[list[float]] +} +``` + +**Response:** +- 400 and an appropiate message in the case of missing fields or invalid field values. There is validation of polygon field, and also a check if the provider id corresponds to the object provider id. +- 200 in the case the service area was successfully updated. + +#### **GET /provider/service-area/:id:** +Retrieves a service area from database. + +**Response:** +- 404 in case the service area with the given id doesn't exist in database. +- 200 in case the service area was found, with the following content: +```javascript +{ + 'name': str, + 'price': float, + 'provider': int, + 'polygon': list[list[float]] +} +``` + +#### **DELETE /provider/service-area/:id:** +Deletes a service area in database. + +**Response:** +- 404 in case the service area with the given id doesn't exist in database. +- 204 in case the service was successfully deleted. + + +#### **GET /provider/service-area/point** +Lists all the service area objects in database that contain the given geopoint. + +**QueryString parameters:** +```javascript + latitude: float + longitude: float +``` +Both parameters are required. + +**Response:** +- 400 if there's missing parameters +- 400 if the values for latitude and longitude are not valid numbers or an invalid latitude/longitude. +- 200 and a list of all service area objects that contain the given point. +```javascript +[ + { + 'name': str, + 'price': float, + 'provider': int, + 'polygon': list[list[float]] + }, + ... +] +``` + +## Cache + +As the database will grow bigger the execution times will grow. To avoid this there is a Redis instance configured to work as cache. + +The cache is a simple dictionary and works as follows: + +- The **WorldArea** model is populated with Polygons that represent a square, covering all the world by dividing the latitude and longitude. Right now the division is one degree, making squares of 1x1 degree which is already a lot, but this can be a improvement in the future. +- Everytime a ServiceArea object is created, updated, or deleted, we search for the intersection of the ServiceArea Polygon and the WorldArea squares, obtaining all the squares where this ServiceArea is contained. We save this information in the cache. +- The cache structure is a dictionary where the key has the following format: **{{LATITUDE}}_{{LONGITUDE}}**, both parameters are truncated to integers. The value is a list of ids referencing ServiceArea objects. +- The idea of the cache is to implement an heuristic to filter the ServiceArea table as much as possible and make the searches as fast a possible when this table grows. + +The cache is implemented in each endpoint as follows: + +#### **DELETE /provider/:id:** + +When a Provider is deleted together with all its ServiceArea objects, we don't need the cache mapping anymore. All the service areas are removed from the corresponding cache entries. + +#### **POST /provider/service-area** + +When a new ServiceArea object is created, the intersection with the WorldArea is calculated and the information is added to the cache. + +#### **PUT /provider/service-area/:id:** + +When a ServiceArea object is updated, the old information is removed from the cache and the new information is added. + +#### **DELETE /provider/service-area/:id:** + +When a ServiceArea is deleted from database, the information is also deleted from the cache. + +#### **GET /provider/service-area/point** + +Here we make use of the cache to query the ServiceArea model. We are provided with a latitude and longitude, so we can manipulate this information to generate the cache key of the WorldArea square that contains the point. + +With this cache key we can quickly obtain all the ServiceArea object ids that belong to the WorldArea square containing the point. + +With these ids we can filter the ServiceArea model. With the result of this query we finally filter for the ServiceArea Polygons that contain the given Point. + + +## Improvements + +- Setup a system to populate the cache at startup. There is a function called **populate_cache** implemented for this task in *apps/parameters/actions/setup_cache.py* +- Implement a system to avoid crashes if the cache is not available, specially in the **GET /provider/service-area/point** view, where we should have a fallback system to return results without applying the heuristic. +- Remove WorldAreas that are water, deserts and other zones where there is no people living. +- Make subdivisions of the squares in WorldArea to improve the precision of the cache in the future. The divisions can be done differently for different areas in the world. Some could require bigger divisions and some smaller. +- Solve the issue with polygon boundaries. The points belonging to the polygon boundaries are not included in the polygon area. + + +## Usage +Project can be built with: +```sh +make build +``` +Then the containers started with: +```sh +make up +``` +And then the tests can be run with: +```sh +make test +``` + +## References + +- [GeoDjango] - GeoDjango tutorial from the official Django docs. + + + + [GeoDjango]: diff --git a/shuttlebackoffice/__init__.py b/apps/parameters/__init__.py similarity index 100% rename from shuttlebackoffice/__init__.py rename to apps/parameters/__init__.py diff --git a/apps/parameters/actions/__init__.py b/apps/parameters/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/parameters/actions/setup_cache.py b/apps/parameters/actions/setup_cache.py new file mode 100644 index 0000000..b3951c3 --- /dev/null +++ b/apps/parameters/actions/setup_cache.py @@ -0,0 +1,29 @@ +import json + +from apps.providers.models import ServiceArea +from apps.utils import get_redis_client + +from ..models import WorldArea + + +def populate_cache(): + redis_instance = get_redis_client() + + for service_area in ServiceArea.objects.all(): + intersection_qs = WorldArea.objects.filter(square__intersects=service_area.polygon) + + if not intersection_qs.exists(): + continue + + for area in intersection_qs: + area_code = area.code + + value = redis_instance.get(area_code) + if not value: + value = list() + else: + value = json.loads(value) + + value.append(service_area.id) + + redis_instance.set(area.code, json.dumps(value)) diff --git a/apps/parameters/app.py b/apps/parameters/app.py new file mode 100644 index 0000000..6e141f0 --- /dev/null +++ b/apps/parameters/app.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ParameterConfig(AppConfig): + default_auto_field = 'django.db.models.AutoField' + name = 'apps.parameters' diff --git a/apps/parameters/migrations/0001_initial.py b/apps/parameters/migrations/0001_initial.py new file mode 100644 index 0000000..148df51 --- /dev/null +++ b/apps/parameters/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-06-03 03:12 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='WorldArea', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=10, unique=True)), + ('square', django.contrib.gis.db.models.fields.PolygonField(srid=4326)), + ], + ), + ] diff --git a/apps/parameters/migrations/0002_datamigration_populate_world_areas.py b/apps/parameters/migrations/0002_datamigration_populate_world_areas.py new file mode 100644 index 0000000..3d6ba91 --- /dev/null +++ b/apps/parameters/migrations/0002_datamigration_populate_world_areas.py @@ -0,0 +1,34 @@ +from django.contrib.gis.geos import Polygon +from django.db import migrations + + +def populate_world_areas(apps, schema_editor): + WorldArea = apps.get_model('parameters', 'WorldArea') + + world_areas = list() + + for latitude in range(-90, 90): + for longitude in range(-180, 180): + world_areas.append( + WorldArea( + square=Polygon([ + [latitude, longitude], + [latitude, longitude + 1], + [latitude + 1, longitude + 1], + [latitude + 1, longitude], + [latitude, longitude] + ]), + code=f'{latitude}_{longitude}') + ) + _ = WorldArea.objects.bulk_create(world_areas) + + +class Migration(migrations.Migration): + + dependencies = [ + ('parameters', '0001_initial'), + ] + + operations = [ + migrations.RunPython(populate_world_areas) + ] diff --git a/apps/parameters/migrations/__init__.py b/apps/parameters/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/parameters/models/__init__.py b/apps/parameters/models/__init__.py new file mode 100644 index 0000000..c0c2850 --- /dev/null +++ b/apps/parameters/models/__init__.py @@ -0,0 +1 @@ +from .world_area import WorldArea diff --git a/apps/parameters/models/world_area.py b/apps/parameters/models/world_area.py new file mode 100644 index 0000000..d41694c --- /dev/null +++ b/apps/parameters/models/world_area.py @@ -0,0 +1,11 @@ +from django.contrib.gis.db import models + + +class WorldArea(models.Model): + code = models.CharField( + max_length=10, + blank=False, + null=False, + unique=True + ) + square = models.PolygonField() diff --git a/apps/providers/__init__.py b/apps/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/actions/__init__.py b/apps/providers/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/actions/get_object.py b/apps/providers/actions/get_object.py new file mode 100644 index 0000000..d950c0f --- /dev/null +++ b/apps/providers/actions/get_object.py @@ -0,0 +1,17 @@ +import json +import redis + +from typing import Union, Set + +from apps.utils import get_redis_client + + +def get_object_from_cache(cache_key: str, /, *, redis_instance: redis.StrictRedis = None) -> Union[Set[int], list]: + if not redis_instance: + redis_instance = get_redis_client() + + value = redis_instance.get(cache_key) + if not value: + return [] + + return set(json.loads(value)) diff --git a/apps/providers/actions/update_cache.py b/apps/providers/actions/update_cache.py new file mode 100644 index 0000000..de089b8 --- /dev/null +++ b/apps/providers/actions/update_cache.py @@ -0,0 +1,70 @@ +import json +import redis + +from django.contrib.gis.geos import Polygon +from django.db.models import QuerySet +from typing import Optional + +from apps.parameters.models import WorldArea +from apps.utils import get_redis_client + +from ..models import ServiceArea + + +def add_new_polygon_to_cache(service_area: ServiceArea, /, *, delete_polygon: Optional[Polygon] = None): + redis_instance = get_redis_client() + + intersection_qs = WorldArea.objects.filter(square__intersects=service_area.polygon) + + if intersection_qs.exists(): + for area in intersection_qs: + area_code = area.code + + value = redis_instance.get(area_code) + if not value: + value = list() + else: + value = json.loads(value) + + value.append(service_area.id) + + redis_instance.set(area.code, json.dumps(value)) + + if delete_polygon: + delete_polygon_from_cache(service_area, delete_polygon, redis_instance=redis_instance) + + +def delete_polygon_from_cache( + service_area: ServiceArea, + delete_polygon: Polygon, + /, + *, + redis_instance: redis.StrictRedis = None): + + if not redis_instance: + redis_instance = get_redis_client() + + intersection_qs = WorldArea.objects.filter(square__intersects=delete_polygon) + + for area in intersection_qs: + area_code = area.code + + value = redis_instance.get(area_code) + if not value: + continue + + value = set(json.loads(value)) + try: + value.remove(service_area.id) + except KeyError: + continue + + value = list(value) + redis_instance.set(area.code, json.dumps(value)) + + +def delete_polygon_batch_from_cache(service_areas: QuerySet): + redis_instance = get_redis_client() + + for service_area in service_areas: + delete_polygon_from_cache(service_area, service_area.polygon, redis_instance=redis_instance) diff --git a/apps/providers/app.py b/apps/providers/app.py new file mode 100644 index 0000000..a51c12c --- /dev/null +++ b/apps/providers/app.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProviderConfig(AppConfig): + default_auto_field = 'django.db.models.AutoField' + name = 'apps.providers' diff --git a/apps/providers/migrations/0001_initial.py b/apps/providers/migrations/0001_initial.py new file mode 100644 index 0000000..fac5870 --- /dev/null +++ b/apps/providers/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.13 on 2022-06-02 22:05 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Provider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('currency', models.CharField(max_length=3)), + ('email', models.EmailField(max_length=254)), + ('language', models.CharField(max_length=2)), + ('name', models.CharField(max_length=200)), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), + ('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)), + ], + ), + migrations.CreateModel( + name='ServiceArea', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('price', models.FloatField(default=0.0)), + ('polygon', django.contrib.gis.db.models.fields.PolygonField(srid=4326)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='providers.provider')), + ], + ), + ] diff --git a/apps/providers/migrations/__init__.py b/apps/providers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/models/__init__.py b/apps/providers/models/__init__.py new file mode 100644 index 0000000..e287f75 --- /dev/null +++ b/apps/providers/models/__init__.py @@ -0,0 +1,2 @@ +from .area import ServiceArea +from .provider import Provider diff --git a/apps/providers/models/area.py b/apps/providers/models/area.py new file mode 100644 index 0000000..b152cb5 --- /dev/null +++ b/apps/providers/models/area.py @@ -0,0 +1,23 @@ +from django.contrib.gis.db import models + +from .provider import Provider + + +class ServiceArea(models.Model): + name = models.CharField( + max_length=200, + blank=False, + null=False + ) + price = models.FloatField( + default=0.0 + ) + + polygon = models.PolygonField() + + provider = models.ForeignKey( + Provider, + on_delete=models.DO_NOTHING, + blank=False, + null=False + ) diff --git a/apps/providers/models/provider.py b/apps/providers/models/provider.py new file mode 100644 index 0000000..88b85a9 --- /dev/null +++ b/apps/providers/models/provider.py @@ -0,0 +1,36 @@ +from django.db import models + +from phonenumber_field.modelfields import PhoneNumberField + + +class Provider(models.Model): + # ISO 4217 + currency = models.CharField( + max_length=3, + blank=False, + null=False + ) + email = models.EmailField( + blank=False, + null=False + ) + # ISO 639-3 + language = models.CharField( + max_length=2, + blank=False, + null=False + ) + name = models.CharField( + max_length=200, + blank=False, + null=False + ) + phone_number = PhoneNumberField( + blank=False, + null=False + ) + + timestamp = models.DateTimeField( + auto_now_add=True, + db_index=True + ) diff --git a/apps/providers/serializers/__init__.py b/apps/providers/serializers/__init__.py new file mode 100644 index 0000000..9f95495 --- /dev/null +++ b/apps/providers/serializers/__init__.py @@ -0,0 +1,2 @@ +from .area import AreaValidationSerializer, ServiceAreaSerializer, AreaUpdateValidationSerializer +from .provider import ProviderSerializer diff --git a/apps/providers/serializers/area.py b/apps/providers/serializers/area.py new file mode 100644 index 0000000..3bc8e0f --- /dev/null +++ b/apps/providers/serializers/area.py @@ -0,0 +1,69 @@ +from rest_framework import serializers + +from django.contrib.gis.geos import Polygon +from django.contrib.gis.geos.error import GEOSException +from rest_framework.exceptions import ValidationError + +from ..models import ServiceArea + + +class AreaValidationSerializer(serializers.ModelSerializer): + price = serializers.FloatField(required=True) + + class Meta: + model = ServiceArea + fields = '__all__' + + def validate_name(self, value): + if not value: + raise ValidationError('Field name is required.') + + return value + + def validate_provider(self, value): + try: + _ = int(value.id) + except ValueError: + raise ValidationError('Field provider has an incorrect type.') + + return value.id + + def validate_polygon(self, value): + try: + _ = Polygon(value) + except GEOSException: + raise ValidationError('Invalid polygon.') + + return value + + +class AreaUpdateValidationSerializer(AreaValidationSerializer): + price = serializers.FloatField(required=True) + + class Meta: + model = ServiceArea + fields = '__all__' + + def validate_provider(self, value): + try: + _ = int(value.id) + except ValueError: + raise ValidationError('Field provider has an incorrect type.') + + obj_provider_id = self.context.get('area_provider_id') + if value.id != obj_provider_id: + raise ValidationError('Provider does not match.') + + return value.id + + +class ServiceAreaSerializer(serializers.ModelSerializer): + class Meta: + model = ServiceArea + fields = '__all__' + + def to_representation(self, instance): + data = super(ServiceAreaSerializer, self).to_representation(instance) + data['polygon'] = instance.polygon.coords[0] + return data + diff --git a/apps/providers/serializers/provider.py b/apps/providers/serializers/provider.py new file mode 100644 index 0000000..85c6fb6 --- /dev/null +++ b/apps/providers/serializers/provider.py @@ -0,0 +1,31 @@ +import pycountry + +from rest_framework import serializers + +from rest_framework.exceptions import ValidationError + +from ..models import Provider + + +class ProviderSerializer(serializers.ModelSerializer): + + class Meta: + model = Provider + fields = '__all__' + read_only_fields = ('id', 'timestamp') + + def validate_language(self, value): + try: + _ = pycountry.languages.lookup(value) + except LookupError: + raise ValidationError(f'Invalid value for language: {value}.') + + return value + + def validate_currency(self, value): + try: + _ = pycountry.currencies.lookup(value) + except LookupError: + raise ValidationError(f'Invalid value for currency: {value}.') + + return value diff --git a/apps/providers/tests/__init__.py b/apps/providers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/tests/test_find_service_areas.py b/apps/providers/tests/test_find_service_areas.py new file mode 100644 index 0000000..9307f1f --- /dev/null +++ b/apps/providers/tests/test_find_service_areas.py @@ -0,0 +1,246 @@ +from django.contrib.gis.geos import Polygon +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from unittest import mock + +from ..models import Provider, ServiceArea + + +class FindServiceAreaCreateTests(APITestCase): + endpoint = reverse('find-service-area') + + @classmethod + def setUpTestData(cls): + super(FindServiceAreaCreateTests, cls).setUpTestData() + + cls.provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + }) + cls.another_provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'another@email.com', + 'language': 'SP', + 'name': 'Test 2', + 'phone_number': '+34685061456' + }) + cls.last_provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'last@email.com', + 'language': 'FR', + 'name': 'Test 3', + 'phone_number': '+34685061776' + }) + + cls.service_area = ServiceArea.objects.create( + polygon=Polygon([[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]), + provider_id=cls.provider.id, + price=500.5, + name='Test area' + ) + cls.another_service_area = ServiceArea.objects.create( + polygon=Polygon([[50.0, 50.0], [50.0, 100.0], [100.0, 100.0], [100.0, 50.0], [50.0, 50.0]]), + provider_id=cls.another_provider.id, + price=85.5, + name='Test area 2' + ) + cls.last_service_area = ServiceArea.objects.create( + polygon=Polygon([[75.0, 50.0], [75.0, 75.0], [100.0, 75.0], [100.0, 50.0], [75.0, 50.0]]), + provider_id=cls.last_provider.id, + price=5.2, + name='Test area 2' + ) + + def setUp(self) -> None: + super(FindServiceAreaCreateTests, self).setUp() + self.get_object_from_cache_mock = mock.patch( + 'apps.providers.views.find_service_area.get_object_from_cache' + ).start() + self.get_object_from_cache_mock.return_value = True + + def tearDown(self) -> None: + super(FindServiceAreaCreateTests, self).tearDown() + self.get_object_from_cache_mock.reset_mock() + mock.patch.stopall() + + def test_find_service_area_case_one_result(self): + self.get_object_from_cache_mock.return_value = {self.service_area.id} + + querystring = '?latitude=25.0&longitude=25.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + + self.assertEqual(response_data['count'], 1) + self.assertEqual(response_data['results'][0]['id'], self.service_area.id) + + self.get_object_from_cache_mock.assert_called_once() + + def test_find_service_area_case_boundary_zero_results(self): + self.get_object_from_cache_mock.return_value = [] + + querystring = '?latitude=50.0&longitude=50.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + + self.assertEqual(response_data['count'], 0) + + self.get_object_from_cache_mock.assert_called_once() + + def test_find_service_area_case_outside_zero_results(self): + self.get_object_from_cache_mock.return_value = [] + + querystring = '?latitude=-40.0&longitude=0.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + + self.assertEqual(response_data['count'], 0) + + self.get_object_from_cache_mock.assert_called_once() + + def test_find_service_area_case_inside_two_results(self): + self.get_object_from_cache_mock.return_value = {self.another_service_area.id, self.last_service_area.id} + + querystring = '?latitude=80.0&longitude=60.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + + expected_ids = {self.another_service_area.id, self.last_service_area.id} + + self.assertEqual(response_data['count'], len(expected_ids)) + + for result in response_data['results']: + self.assertIn(result['id'], expected_ids) + + self.get_object_from_cache_mock.assert_called_once() + + # failure tests + + def test_find_service_area_failure_missing_latitude(self): + querystring = '?longitude=25.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('latitude', response_data) + self.assertEqual(response_data['latitude'], 'Invalid value: None.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_missing_longitude(self): + querystring = '?latitude=25.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('longitude', response_data) + self.assertEqual(response_data['longitude'], 'Invalid value: None.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_invalid_value_for_latitude(self): + invalid_value = 'aa' + querystring = f'?latitude={invalid_value}&longitude=25.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('latitude', response_data) + self.assertEqual(response_data['latitude'], f'Invalid value: {invalid_value}.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_invalid_latitude_lower(self): + invalid_value = -94.1235 + querystring = f'?latitude={invalid_value}&longitude=25.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('latitude', response_data) + self.assertEqual(response_data['latitude'], f'Invalid value: {invalid_value}.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_invalid_latitude_greater(self): + invalid_value = 90.1235 + querystring = f'?latitude={invalid_value}&longitude=25.0' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('latitude', response_data) + self.assertEqual(response_data['latitude'], f'Invalid value: {invalid_value}.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_invalid_value_for_longitude(self): + invalid_value = 'aa' + querystring = f'?latitude=25.0&longitude={invalid_value}' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('longitude', response_data) + self.assertEqual(response_data['longitude'], f'Invalid value: {invalid_value}.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_invalid_longitude_lower(self): + invalid_value = -200.0123 + querystring = f'?latitude=25.0&longitude={invalid_value}' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('longitude', response_data) + self.assertEqual(response_data['longitude'], f'Invalid value: {invalid_value}.') + + self.get_object_from_cache_mock.assert_not_called() + + def test_find_service_area_failure_invalid_longitude_greater(self): + invalid_value = 190.0123 + querystring = f'?latitude=25.0&longitude={invalid_value}' + + response = self.client.get( + self.endpoint + querystring + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('longitude', response_data) + self.assertEqual(response_data['longitude'], f'Invalid value: {invalid_value}.') + + self.get_object_from_cache_mock.assert_not_called() diff --git a/apps/providers/tests/test_provider_create.py b/apps/providers/tests/test_provider_create.py new file mode 100644 index 0000000..5f9f3d4 --- /dev/null +++ b/apps/providers/tests/test_provider_create.py @@ -0,0 +1,200 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + + +class ProvidersCreateTests(APITestCase): + endpoint = reverse('provider-list') + + def test_providers_create_success(self): + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response_data = response.json() + for field in payload.keys(): + self.assertIn(field, response_data) + self.assertEqual(payload[field], response_data[field]) + + self.assertIn('id', response_data) + self.assertIn('timestamp', response_data) + + # failure tests + + def test_providers_create_failure_missing_field_currency(self): + payload = { + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('currency', response_data) + self.assertEqual(response_data['currency'][0], 'This field is required.') + + def test_providers_create_failure_missing_field_email(self): + payload = { + 'currency': 'USD', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('email', response_data) + self.assertEqual(response_data['email'][0], 'This field is required.') + + def test_providers_create_failure_missing_field_language(self): + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('language', response_data) + self.assertEqual(response_data['language'][0], 'This field is required.') + + def test_providers_create_failure_missing_field_name(self): + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('name', response_data) + self.assertEqual(response_data['name'][0], 'This field is required.') + + def test_providers_create_failure_missing_field_phone_number(self): + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('phone_number', response_data) + self.assertEqual(response_data['phone_number'][0], 'This field is required.') + + def test_providers_create_failure_invalid_currency(self): + payload = { + 'currency': '55', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('currency', response_data) + self.assertEqual(response_data['currency'][0], f'Invalid value for currency: {payload["currency"]}.') + + def test_providers_create_failure_invalid_email(self): + payload = { + 'currency': 'USD', + 'email': 'nicolasemail.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('email', response_data) + self.assertEqual(response_data['email'][0], 'Enter a valid email address.') + + def test_providers_create_failure_invalid_language(self): + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'T', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('language', response_data) + self.assertEqual(response_data['language'][0], f'Invalid value for language: {payload["language"]}.') + + def test_providers_create_failure_invalid_phone_number(self): + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': 'invalid_phone' + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + + self.assertIn('phone_number', response_data) + self.assertEqual(response_data['phone_number'][0], 'The phone number entered is not valid.') diff --git a/apps/providers/tests/test_provider_delete.py b/apps/providers/tests/test_provider_delete.py new file mode 100644 index 0000000..a1304eb --- /dev/null +++ b/apps/providers/tests/test_provider_delete.py @@ -0,0 +1,70 @@ +from django.contrib.gis.geos import Polygon +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from unittest import mock + +from ..models import Provider, ServiceArea + + +class ProvidersDeleteTests(APITestCase): + endpoint_name = 'provider-detail' + + @classmethod + def setUpTestData(cls): + super(ProvidersDeleteTests, cls).setUpTestData() + + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + cls.provider = Provider.objects.create(**payload) + + cls.service_area = ServiceArea.objects.create( + polygon=Polygon([[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]), + provider_id=cls.provider.id, + price=500.5, + name='Test area' + ) + + def setUp(self) -> None: + super(ProvidersDeleteTests, self).setUp() + self.delete_polygon_batch_from_cache_mock = mock.patch( + 'apps.providers.views.provider.delete_polygon_batch_from_cache' + ).start() + self.delete_polygon_batch_from_cache_mock.return_value = True + + def tearDown(self) -> None: + super(ProvidersDeleteTests, self).tearDown() + self.delete_polygon_batch_from_cache_mock.reset_mock() + mock.patch.stopall() + + def test_provider_delete_success(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.provider.id}) + + provider_id = self.provider.id + service_area_id = self.service_area.id + + response = self.client.delete( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + self.assertFalse(Provider.objects.filter(pk=provider_id).exists()) + self.assertFalse(ServiceArea.objects.filter(pk=service_area_id).exists()) + + self.delete_polygon_batch_from_cache_mock.assert_called_once() + + def test_provider_delete_failure_does_not_exist(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.provider.id + 100}) + + response = self.client.delete( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.delete_polygon_batch_from_cache_mock.assert_not_called() diff --git a/apps/providers/tests/test_provider_list.py b/apps/providers/tests/test_provider_list.py new file mode 100644 index 0000000..a944546 --- /dev/null +++ b/apps/providers/tests/test_provider_list.py @@ -0,0 +1,44 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models import Provider + + +class ProvidersUpdateTests(APITestCase): + endpoint = reverse('provider-list') + + @classmethod + def setUpTestData(cls): + super(ProvidersUpdateTests, cls).setUpTestData() + + cls.provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + }) + cls.provider_two = Provider.objects.create(**{ + 'currency': 'EUR', + 'email': 'user@email.com', + 'language': 'ES', + 'name': 'Test 2', + 'phone_number': '+34685065551' + }) + + def test_provider_list(self): + response = self.client.get( + self.endpoint + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response_data = response.json() + + expected_ids = {self.provider.id, self.provider_two.id} + + self.assertEqual(response_data['count'], len(expected_ids)) + results = response_data['results'] + + for provider in results: + self.assertIn(provider['id'], expected_ids) diff --git a/apps/providers/tests/test_provider_retrieve.py b/apps/providers/tests/test_provider_retrieve.py new file mode 100644 index 0000000..8cd6b01 --- /dev/null +++ b/apps/providers/tests/test_provider_retrieve.py @@ -0,0 +1,41 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models import Provider + + +class ProvidersRetrieveTests(APITestCase): + endpoint_name = 'provider-detail' + + @classmethod + def setUpTestData(cls): + super(ProvidersRetrieveTests, cls).setUpTestData() + + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + cls.provider = Provider.objects.create(**payload) + + def test_provider_retrieve_success(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.provider.id}) + + response = self.client.get( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + self.assertEqual(response_data['id'], self.provider.id) + + def test_provider_retrieve_failure_does_not_exist(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.provider.id + 100}) + + response = self.client.get( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/apps/providers/tests/test_provider_update.py b/apps/providers/tests/test_provider_update.py new file mode 100644 index 0000000..84b97e7 --- /dev/null +++ b/apps/providers/tests/test_provider_update.py @@ -0,0 +1,52 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models import Provider + + +class ProvidersUpdateTests(APITestCase): + endpoint_name = 'provider-detail' + + @classmethod + def setUpTestData(cls): + super(ProvidersUpdateTests, cls).setUpTestData() + + payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + cls.provider = Provider.objects.create(**payload) + + def test_provider_update_success(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.provider.id}) + + payload = { + 'currency': 'EUR', + 'email': 'user@email.com', + 'language': 'ES', + 'name': 'Test 2', + 'phone_number': '+34685061111' + } + response = self.client.put( + endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response_data = response.json() + for field in payload.keys(): + self.assertEqual(payload[field], response_data[field]) + + def test_provider_update_failure_does_not_exist(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.provider.id + 100}) + + response = self.client.put( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/apps/providers/tests/test_service_area_create.py b/apps/providers/tests/test_service_area_create.py new file mode 100644 index 0000000..9df04bc --- /dev/null +++ b/apps/providers/tests/test_service_area_create.py @@ -0,0 +1,179 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from unittest import mock + +from ..models import Provider, ServiceArea + + +class ServiceAreaCreateTests(APITestCase): + endpoint = reverse('service-area-list') + + @classmethod + def setUpTestData(cls): + super(ServiceAreaCreateTests, cls).setUpTestData() + + cls.payload = { + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + } + + cls.provider = Provider.objects.create(**cls.payload) + + def setUp(self) -> None: + super(ServiceAreaCreateTests, self).setUp() + self.add_new_polygon_to_cache_mock = mock.patch('apps.providers.views.area.add_new_polygon_to_cache').start() + self.add_new_polygon_to_cache_mock.return_value = True + + def tearDown(self) -> None: + super(ServiceAreaCreateTests, self).tearDown() + self.add_new_polygon_to_cache_mock.reset_mock() + mock.patch.stopall() + + def test_service_area_create_success(self): + payload = { + 'name': 'Test area', + 'price': 500.5, + 'provider': self.provider.id, + 'polygon': [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]] + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + service_area_qs = ServiceArea.objects.all() + + self.assertEqual(service_area_qs.count(), 1) + service_area_obj = service_area_qs.first() + self.assertEqual(service_area_obj.provider_id, payload['provider']) + self.assertEqual(service_area_obj.name, payload['name']) + self.assertEqual(service_area_obj.price, payload['price']) + self.assertEqual(service_area_obj.polygon.num_points, len(payload['polygon'])) + + self.add_new_polygon_to_cache_mock.assert_called_once() + + # failure tests + + def test_service_area_create_failure_missing_required_field_name(self): + payload = { + 'price': 500.5, + 'provider': self.provider.id, + 'polygon': [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]] + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('name', response_data) + self.assertEqual(response_data['name'][0], 'This field is required.') + + self.add_new_polygon_to_cache_mock.assert_not_called() + + def test_service_area_create_failure_missing_required_field_price(self): + payload = { + 'name': 'Test area', + 'provider': self.provider.id, + 'polygon': [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]] + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('price', response_data) + self.assertEqual(response_data['price'][0], 'This field is required.') + + self.add_new_polygon_to_cache_mock.assert_not_called() + + def test_service_area_create_failure_missing_required_field_provider(self): + payload = { + 'name': 'Test area', + 'price': 500.5, + 'polygon': [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]] + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('provider', response_data) + self.assertEqual(response_data['provider'][0], 'This field is required.') + + self.add_new_polygon_to_cache_mock.assert_not_called() + + def test_service_area_create_failure_missing_required_field_polygon(self): + payload = { + 'name': 'Test area', + 'price': 500.5, + 'provider': self.provider.id + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('polygon', response_data) + self.assertEqual(response_data['polygon'][0], 'This field is required.') + + self.add_new_polygon_to_cache_mock.assert_not_called() + + def test_service_area_create_failure_invalid_provider_type(self): + payload = { + 'name': 'Test area', + 'price': 500.5, + 'provider': 'provider', + 'polygon': [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]] + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('provider', response_data) + self.assertEqual(response_data['provider'][0], 'Incorrect type. Expected pk value, received str.') + + self.add_new_polygon_to_cache_mock.assert_not_called() + + def test_service_area_create_failure_invalid_provider_id(self): + invalid_id = self.provider.id + 100 + payload = { + 'name': 'Test area', + 'price': 500.5, + 'provider': invalid_id, + 'polygon': [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]] + } + + response = self.client.post( + self.endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('provider', response_data) + self.assertEqual(response_data['provider'][0], f'Invalid pk "{invalid_id}" - object does not exist.') + + self.add_new_polygon_to_cache_mock.assert_not_called() diff --git a/apps/providers/tests/test_service_area_delete.py b/apps/providers/tests/test_service_area_delete.py new file mode 100644 index 0000000..86fdeae --- /dev/null +++ b/apps/providers/tests/test_service_area_delete.py @@ -0,0 +1,67 @@ +from django.contrib.gis.geos import Polygon +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from unittest import mock + +from ..models import Provider, ServiceArea + + +class ServiceAreaDeleteTests(APITestCase): + endpoint_name = 'service-area-detail' + + @classmethod + def setUpTestData(cls): + super(ServiceAreaDeleteTests, cls).setUpTestData() + + cls.provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + }) + + cls.service_area = ServiceArea.objects.create( + polygon=Polygon([[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]), + provider_id=cls.provider.id, + price=500.5, + name='Test area' + ) + + def setUp(self) -> None: + super(ServiceAreaDeleteTests, self).setUp() + self.delete_polygon_from_cache_from_cache_mock = mock.patch( + 'apps.providers.views.area.delete_polygon_from_cache' + ).start() + self.delete_polygon_from_cache_from_cache_mock.return_value = True + + def tearDown(self) -> None: + super(ServiceAreaDeleteTests, self).tearDown() + self.delete_polygon_from_cache_from_cache_mock.reset_mock() + mock.patch.stopall() + + def test_service_area_delete_success(self): + service_area_id = self.service_area.id + provider_id = self.provider.id + endpoint = reverse(self.endpoint_name, kwargs={'pk': service_area_id}) + + response = self.client.delete( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + self.assertFalse(ServiceArea.objects.filter(pk=service_area_id).exists()) + self.assertTrue(Provider.objects.filter(pk=provider_id).exists()) + + self.delete_polygon_from_cache_from_cache_mock.assert_called_once() + + def test_service_area_delete_failure_invalid_id(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.service_area.id + 100}) + + response = self.client.delete( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.delete_polygon_from_cache_from_cache_mock.assert_not_called() diff --git a/apps/providers/tests/test_service_area_list.py b/apps/providers/tests/test_service_area_list.py new file mode 100644 index 0000000..1bd7339 --- /dev/null +++ b/apps/providers/tests/test_service_area_list.py @@ -0,0 +1,58 @@ +from django.contrib.gis.geos import Polygon +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models import Provider, ServiceArea + + +class ServiceAreaCreateTests(APITestCase): + endpoint = reverse('service-area-list') + + @classmethod + def setUpTestData(cls): + super(ServiceAreaCreateTests, cls).setUpTestData() + + cls.provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + }) + + cls.another_provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'another@email.com', + 'language': 'SP', + 'name': 'Test 2', + 'phone_number': '+34685061456' + }) + + cls.service_area = ServiceArea.objects.create( + polygon=Polygon([[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]), + provider_id=cls.provider.id, + price=500.5, + name='Test area' + ) + cls.another_service_area = ServiceArea.objects.create( + polygon=Polygon([[50.0, 50.0], [50.0, 100.0], [100.0, 100.0], [100.0, 50.0], [50.0, 50.0]]), + provider_id=cls.another_provider.id, + price=85.5, + name='Test area 2' + ) + + def test_service_area_list(self): + response = self.client.get( + self.endpoint + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + + expected_ids = {self.service_area.id, self.another_service_area.id} + + self.assertEqual(response_data['count'], len(expected_ids)) + results = response_data['results'] + + for result in results: + self.assertIn(result['id'], expected_ids) diff --git a/apps/providers/tests/test_service_area_retrieve.py b/apps/providers/tests/test_service_area_retrieve.py new file mode 100644 index 0000000..7f51297 --- /dev/null +++ b/apps/providers/tests/test_service_area_retrieve.py @@ -0,0 +1,48 @@ +from django.contrib.gis.geos import Polygon +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models import Provider, ServiceArea + + +class ServiceAreaRetrieveTests(APITestCase): + endpoint_name = 'service-area-detail' + + @classmethod + def setUpTestData(cls): + super(ServiceAreaRetrieveTests, cls).setUpTestData() + + cls.provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + }) + + cls.service_area = ServiceArea.objects.create( + polygon=Polygon([[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]), + provider_id=cls.provider.id, + price=500.5, + name='Test area' + ) + + def test_service_area_retrieve_success(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.service_area.id}) + + response = self.client.get( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + + self.assertEqual(response_data['id'], self.service_area.id) + + def test_service_area_retrieve_failure_invalid_id(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.service_area.id + 100}) + + response = self.client.get( + endpoint + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/apps/providers/tests/test_service_area_update.py b/apps/providers/tests/test_service_area_update.py new file mode 100644 index 0000000..fe04056 --- /dev/null +++ b/apps/providers/tests/test_service_area_update.py @@ -0,0 +1,116 @@ +from django.contrib.gis.geos import Polygon +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from unittest import mock + +from ..models import Provider, ServiceArea + + +class ServiceAreaUpdateTests(APITestCase): + endpoint_name = 'service-area-detail' + + @classmethod + def setUpTestData(cls): + super(ServiceAreaUpdateTests, cls).setUpTestData() + + cls.provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'nicolas@email.com', + 'language': 'EN', + 'name': 'Test', + 'phone_number': '+34685061901' + }) + + cls.another_provider = Provider.objects.create(**{ + 'currency': 'USD', + 'email': 'another@email.com', + 'language': 'SP', + 'name': 'Test 2', + 'phone_number': '+34685061456' + }) + + cls.service_area = ServiceArea.objects.create( + polygon=Polygon([[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]), + provider_id=cls.provider.id, + price=500.5, + name='Test area' + ) + + def setUp(self) -> None: + super(ServiceAreaUpdateTests, self).setUp() + self.add_new_polygon_to_cache_mock = mock.patch('apps.providers.views.area.add_new_polygon_to_cache').start() + self.add_new_polygon_to_cache_mock.return_value = True + + def tearDown(self) -> None: + super(ServiceAreaUpdateTests, self).tearDown() + self.add_new_polygon_to_cache_mock.reset_mock() + mock.patch.stopall() + + # NOTE ---- Failure tests related to serializer validation are done in the create test class and not repeated here + + def test_service_area_update_success(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.service_area.id}) + + payload = { + 'name': 'Test area 2', + 'price': 12.5, + 'provider': self.provider.id, + 'polygon': [[50.0, 50.0], [50.0, 100.0], [100.0, 100.0], [100.0, 50.0], [50.0, 50.0]] + } + + response = self.client.put( + endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.service_area.refresh_from_db() + + self.assertEqual(self.service_area.name, payload['name']) + self.assertEqual(self.service_area.price, payload['price']) + self.assertEqual(self.service_area.provider_id, self.provider.id) + + self.add_new_polygon_to_cache_mock.assert_called_once() + + def test_service_area_update_failure_provider_does_not_match(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.service_area.id}) + + payload = { + 'name': 'Test area 2', + 'price': 12.5, + 'provider': self.another_provider.id, + 'polygon': [[50.0, 50.0], [50.0, 100.0], [100.0, 100.0], [100.0, 50.0], [50.0, 50.0]] + } + + response = self.client.put( + endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.json() + self.assertIn('provider', response_data) + self.assertEqual(response_data['provider'][0], 'Provider does not match.') + + self.add_new_polygon_to_cache_mock.assert_not_called() + + def test_service_area_update_failure_not_found(self): + endpoint = reverse(self.endpoint_name, kwargs={'pk': self.service_area.id + 100}) + + payload = { + 'name': 'Test area 2', + 'price': 12.5, + 'provider': self.another_provider.id, + 'polygon': [[50.0, 50.0], [50.0, 100.0], [100.0, 100.0], [100.0, 50.0], [50.0, 50.0]] + } + + response = self.client.put( + endpoint, + payload, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.add_new_polygon_to_cache_mock.assert_not_called() diff --git a/apps/providers/urls.py b/apps/providers/urls.py new file mode 100644 index 0000000..7b9a9a8 --- /dev/null +++ b/apps/providers/urls.py @@ -0,0 +1,38 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + + path( + '', + views.ProvidersViewSet.as_view({'post': 'create', 'get': 'list'}), + name='provider-list' + ), + + path( + '/', + views.ProvidersViewSet.as_view({'put': 'update', 'get': 'retrieve', 'delete': 'destroy'}), + name='provider-detail' + ), + + path( + 'service-area/', + views.ServiceAreaViewSet.as_view({'post': 'create_service_area', 'get': 'list'}), + name='service-area-list' + ), + + path( + 'service-area//', + views.ServiceAreaViewSet.as_view({'put': 'update_service_area', 'get': 'retrieve', 'delete': 'destroy'}), + name='service-area-detail' + ), + + path( + 'service-area/point/', + views.FindServiceAreaView.as_view({'get': 'find_service_areas'}), + name='find-service-area' + ), + +] diff --git a/apps/providers/views/__init__.py b/apps/providers/views/__init__.py new file mode 100644 index 0000000..790732e --- /dev/null +++ b/apps/providers/views/__init__.py @@ -0,0 +1,3 @@ +from .area import ServiceAreaViewSet +from .find_service_area import FindServiceAreaView +from .provider import ProvidersViewSet diff --git a/apps/providers/views/area.py b/apps/providers/views/area.py new file mode 100644 index 0000000..3d693e2 --- /dev/null +++ b/apps/providers/views/area.py @@ -0,0 +1,62 @@ +from rest_framework import status + +from django.contrib.gis.geos import Polygon +from rest_framework.mixins import RetrieveModelMixin, ListModelMixin, DestroyModelMixin +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from ..actions.update_cache import add_new_polygon_to_cache, delete_polygon_from_cache +from ..models import ServiceArea +from ..serializers import AreaValidationSerializer, ServiceAreaSerializer, AreaUpdateValidationSerializer + + +class ServiceAreaViewSet(RetrieveModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet): + + queryset = ServiceArea.objects.all() + serializer_class = ServiceAreaSerializer + + def create_service_area(self, request, *args, **kwargs): + payload = request.data + + serializer = AreaValidationSerializer(data=payload) + serializer.is_valid(raise_exception=True) + + service_area_obj = ServiceArea.objects.create( + name=payload.get('name'), + price=payload.get('price'), + polygon=Polygon(payload.get('polygon')), + provider_id=payload.get('provider') + ) + + add_new_polygon_to_cache(service_area_obj) + + return Response(status=status.HTTP_201_CREATED) + + def update_service_area(self, request, *args, **kwargs): + service_area_obj = self.get_object() + + payload = request.data + + serializer = AreaUpdateValidationSerializer( + data=payload, + context={'area_provider_id': service_area_obj.provider_id} + ) + serializer.is_valid(raise_exception=True) + + old_polygon = service_area_obj.polygon + + _ = ServiceArea.objects.filter(id=service_area_obj.id).update( + name=payload.get('name'), + price=payload.get('price'), + polygon=Polygon(payload.get('polygon')) + ) + + service_area_obj.refresh_from_db() + add_new_polygon_to_cache(service_area_obj, delete_polygon=old_polygon) + + return Response(status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + service_area_obj = self.get_object() + delete_polygon_from_cache(service_area_obj, service_area_obj.polygon) + return super(ServiceAreaViewSet, self).destroy(request) diff --git a/apps/providers/views/find_service_area.py b/apps/providers/views/find_service_area.py new file mode 100644 index 0000000..bce35e6 --- /dev/null +++ b/apps/providers/views/find_service_area.py @@ -0,0 +1,74 @@ +from rest_framework import status + +from django.contrib.gis.geos import Point +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet +from typing import Optional, Tuple + +from ..actions.get_object import get_object_from_cache +from ..models import ServiceArea +from ..serializers import ServiceAreaSerializer + + +class FindServiceAreaView(GenericViewSet): + + serializer_class = ServiceAreaSerializer + + def validate_querystring(self, latitude: Optional[str], longitude: Optional[str]) -> Optional[Tuple[float, float]]: + try: + lat = float(latitude) + except (TypeError, ValueError): + raise ValidationError({'latitude': f'Invalid value: {latitude}.'}) + + if lat < -90 or lat > 90: + raise ValidationError({'latitude': f'Invalid value: {lat}.'}) + + try: + lng = float(longitude) + except (TypeError, ValueError): + raise ValidationError({'longitude': f'Invalid value: {longitude}.'}) + + if lng < -180 or lng > 180: + raise ValidationError({'longitude': f'Invalid value: {lng}.'}) + + return lat, lng + + def generate_cache_key(self, latitude, longitude): + latitude = int(latitude) + if latitude < 0: + latitude -= latitude + + longitude = int(longitude) + if longitude < 0: + longitude -= 1 + + return f'{latitude}_{longitude}' + + def find_service_areas(self, request, *args, **kwargs): + query_parameters = self.request.query_params + + latitude = query_parameters.get('latitude') + longitude = query_parameters.get('longitude') + + validated_coordinates = self.validate_querystring(latitude, longitude) + + cache_key = self.generate_cache_key(*validated_coordinates) + service_area_ids = get_object_from_cache(cache_key) + point = Point(validated_coordinates) + + cache_failure = False # TODO: Implement a check to activate fallback system + + if cache_failure: + service_area_qs = ServiceArea.objects.filter(polygon__contains=point) + else: + service_area_qs = ServiceArea.objects.filter(id__in=service_area_ids).filter(polygon__contains=point) + + page = self.paginate_queryset(service_area_qs) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(service_area_qs, many=True) + + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/providers/views/provider.py b/apps/providers/views/provider.py new file mode 100644 index 0000000..aaa7f0a --- /dev/null +++ b/apps/providers/views/provider.py @@ -0,0 +1,21 @@ +from rest_framework.viewsets import ModelViewSet + +from ..actions.update_cache import delete_polygon_batch_from_cache +from ..models import Provider, ServiceArea +from ..serializers import ProviderSerializer + + +class ProvidersViewSet(ModelViewSet): + serializer_class = ProviderSerializer + queryset = Provider.objects.all() + + def destroy(self, request, *args, **kwargs): + provider_id = kwargs['pk'] + + service_area_qs = ServiceArea.objects.filter(provider_id=provider_id) + + if service_area_qs.exists(): + delete_polygon_batch_from_cache(service_area_qs) + _ = service_area_qs.delete() + + return super(ProvidersViewSet, self).destroy(request) diff --git a/apps/utils.py b/apps/utils.py new file mode 100644 index 0000000..9ee8d18 --- /dev/null +++ b/apps/utils.py @@ -0,0 +1,12 @@ +import redis + +from django.conf import settings + + +def get_redis_client() -> redis.StrictRedis: + redis_client = redis.StrictRedis( + host=settings.REDIS_HOSTNAME, + port=settings.REDIS_PORT, + db=settings.REDIS_DB_NUMBER + ) + return redis_client diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..430bdd4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +version: "2" + +services: + db: + image: kartoza/postgis:14-3.2 + volumes: + - db_data:/var/lib/postgres + environment: + POSTGRES_DB: shuttleapp + POSTGRES_USER: backoffice_user + POSTGRES_PASSWORD: qwerty + + redis: + image: redis:3.0 + volumes: + - redis:/data + ports: + - 6379:6379 + command: redis-server --appendonly yes + + shuttleapp: + build: . + restart: unless-stopped + depends_on: + - db + - redis + command: /env/bin/python /app/manage.py runserver 0.0.0.0:8091 + env_file: + - variables.dev + environment: + - DJANGO_SETTINGS_MODULE=shuttleapp.settings.development + - RDS_HOSTNAME=db + - REDIS_HOSTNAME=redis + ports: + - 8091:8091 + volumes: + - .:/app + +volumes: + db_data: + driver: local + redis: + driver: local diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..041c1d9 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/sh +/env/bin/python /app/manage.py migrate --noinput diff --git a/docker/postgres-setup.sql b/docker/postgres-setup.sql new file mode 100644 index 0000000..fdeef12 --- /dev/null +++ b/docker/postgres-setup.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA `shuttleapp`; +CREATE SCHEMA `test_shuttleapp`; +CREATE USER 'backoffice_user'@'%' WITH PASSWORD 'qwerty'; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA shuttleapp.* TO 'backoffice_user'@'%'; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA test_shuttleapp.* TO 'backoffice_user'@'%'; diff --git a/manage.py b/manage.py index c191ab6..247fada 100755 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shuttlebackoffice.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shuttleapp.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/postman/collection.json b/postman/collection.json new file mode 100644 index 0000000..c00f2b4 --- /dev/null +++ b/postman/collection.json @@ -0,0 +1,504 @@ +{ + "info": { + "_postman_id": "b0d4398c-4b29-4d1b-b2ed-66a87669d0cc", + "name": "z_shuttle", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "4159200" + }, + "item": [ + { + "name": "Provider", + "item": [ + { + "name": "CREATE Provider", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 201\", function () {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test(\"Save user ID\", function () {", + " let userId = pm.response.json().id;", + " pm.environment.set(\"USER_ID\", userId);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"currency\": \"USD\",\n \"email\": \"nicolas@email.com\",\n \"language\": \"EN\",\n \"name\": \"Provider #1\",\n \"phone_number\": \"+34685061901\"\n}" + }, + "url": { + "raw": "http://127.0.0.1:8091/provider/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "" + ] + } + }, + "response": [] + }, + { + "name": "LIST Providers", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "" + ] + } + }, + "response": [] + }, + { + "name": "RETRIEVE Provider", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/{{USER_ID}}/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "{{USER_ID}}", + "" + ] + } + }, + "response": [] + }, + { + "name": "UPDATE Provider", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"currency\": \"EUR\",\n \"email\": \"nico@email.com\",\n \"language\": \"ES\",\n \"name\": \"Provider #1.1\",\n \"phone_number\": \"+34685061900\"\n}" + }, + "url": { + "raw": "http://127.0.0.1:8091/provider/{{USER_ID}}/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "{{USER_ID}}", + "" + ] + } + }, + "response": [] + }, + { + "name": "DELETE Provider", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 204\", function () {", + " pm.response.to.have.status(204);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/{{USER_ID}}/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "{{USER_ID}}", + "" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Service Area", + "item": [ + { + "name": "CREATE Service Area", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 201\", function () {", + " pm.response.to.have.status(201);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test area\",\n \"price\": 500.5,\n \"provider\": {{USER_ID}},\n \"polygon\": [[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0], [0.0, 0.0]]\n}" + }, + "url": { + "raw": "http://127.0.0.1:8091/provider/service-area/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "service-area", + "" + ] + } + }, + "response": [] + }, + { + "name": "LIST Service Areas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Save service area ID\", function () {", + " let serviceAreaId = pm.response.json()[\"results\"][0].id;", + " pm.environment.set(\"SERVICE_AREA_ID\", serviceAreaId);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/service-area/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "service-area", + "" + ] + } + }, + "response": [] + }, + { + "name": "RETRIEVE Service Area", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/service-area/{{SERVICE_AREA_ID}}/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "service-area", + "{{SERVICE_AREA_ID}}", + "" + ] + } + }, + "response": [] + }, + { + "name": "UPDATE Service Area", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test area 2\",\n \"price\": 12.5,\n \"provider\": {{USER_ID}},\n \"polygon\": [[50.0, 50.0], [50.0, 100.0], [100.0, 100.0], [100.0, 50.0], [50.0, 50.0]]\n}" + }, + "url": { + "raw": "http://127.0.0.1:8091/provider/service-area/{{SERVICE_AREA_ID}}/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "service-area", + "{{SERVICE_AREA_ID}}", + "" + ] + } + }, + "response": [] + }, + { + "name": "DELETE Service Area", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 204\", function () {", + " pm.response.to.have.status(204);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/service-area/{{SERVICE_AREA_ID}}/", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "service-area", + "{{SERVICE_AREA_ID}}", + "" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "FIND Service Areas", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Save service area ID\", function () {", + " let serviceAreaId = pm.response.json()[\"results\"][0].id;", + " pm.environment.set(\"SERVICE_AREA_ID\", serviceAreaId);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://127.0.0.1:8091/provider/service-area/point?latitude=20.000&longitude=25.1234", + "protocol": "http", + "host": [ + "127", + "0", + "0", + "1" + ], + "port": "8091", + "path": [ + "provider", + "service-area", + "point" + ], + "query": [ + { + "key": "latitude", + "value": "20.000" + }, + { + "key": "longitude", + "value": "25.1234" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a6172d2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +ipdb ~= 0.13.9 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..963bbf8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Django==3.2.13 +django-phonenumber-field==6.1.0 +djangorestframework==3.13.1 + +gunicorn==20.1.0 + +phonenumbers==8.12.49 +psycopg2-binary==2.9.1 +pycountry==22.3.5 + +redis==3.0.0 diff --git a/shuttleapp/__init__.py b/shuttleapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shuttlebackoffice/asgi.py b/shuttleapp/asgi.py similarity index 70% rename from shuttlebackoffice/asgi.py rename to shuttleapp/asgi.py index 8d19651..4bf2677 100644 --- a/shuttlebackoffice/asgi.py +++ b/shuttleapp/asgi.py @@ -1,5 +1,5 @@ """ -ASGI config for shuttlebackoffice project. +ASGI config for shuttleapp project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shuttlebackoffice.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shuttleapp.settings') application = get_asgi_application() diff --git a/shuttleapp/middleware.py b/shuttleapp/middleware.py new file mode 100644 index 0000000..18c9150 --- /dev/null +++ b/shuttleapp/middleware.py @@ -0,0 +1,8 @@ +from django.http import HttpResponse +from django.utils.deprecation import MiddlewareMixin + + +class HealthCheckMiddleware(MiddlewareMixin): + def process_request(self, request): + if request.META['PATH_INFO'] == '/ping/': + return HttpResponse('OK') diff --git a/shuttlebackoffice/settings.py b/shuttleapp/settings/default.py similarity index 69% rename from shuttlebackoffice/settings.py rename to shuttleapp/settings/default.py index ef82dce..070c624 100644 --- a/shuttlebackoffice/settings.py +++ b/shuttleapp/settings/default.py @@ -1,5 +1,5 @@ """ -Django settings for shuttlebackoffice project. +Django settings for shuttleapp project. Generated by 'django-admin startproject' using Django 3.2.13. @@ -9,9 +9,20 @@ https://docs.djangoproject.com/en/3.2/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/3.2/ref/settings/ """ +import os +import sys from pathlib import Path + +def getenv_or_fail(env_name, default_value=None): + value = os.getenv(env_name, default_value) + if value: + return value + print("ERROR: ENV variable named `{}` is not defined.".format(env_name)) + sys.exit(1) + + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,7 +31,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-@(&r&=9w7ggdz34ioet7dnpq26!e64)f-%a@vj_e_)*spbw+06' +SECRET_KEY = getenv_or_fail('DJANGO_SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -37,9 +48,14 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.gis', + + 'apps.providers.app.ProviderConfig', + 'apps.parameters.app.ParameterConfig', ] MIDDLEWARE = [ + 'shuttleapp.middleware.HealthCheckMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -49,7 +65,7 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] -ROOT_URLCONF = 'shuttlebackoffice.urls' +ROOT_URLCONF = 'shuttleapp.urls' TEMPLATES = [ { @@ -67,7 +83,7 @@ TEMPLATES = [ }, ] -WSGI_APPLICATION = 'shuttlebackoffice.wsgi.application' +WSGI_APPLICATION = 'shuttleapp.wsgi.application' # Database @@ -75,12 +91,15 @@ WSGI_APPLICATION = 'shuttlebackoffice.wsgi.application' DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': 'django.contrib.gis.db.backends.postgis', + 'NAME': getenv_or_fail('RDS_DB_NAME'), + 'USER': getenv_or_fail('RDS_USERNAME'), + 'PASSWORD': getenv_or_fail('RDS_PASSWORD'), + 'HOST': getenv_or_fail('RDS_HOSTNAME'), + 'PORT': getenv_or_fail('RDS_PORT') } } - # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators @@ -123,3 +142,16 @@ STATIC_URL = '/static/' # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# +# GDAL_LIBRARY_PATH = '/usr/lib/lib/libgdal.so' + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20 +} + +# REDIS +REDIS_HOSTNAME = getenv_or_fail('REDIS_HOSTNAME') +REDIS_PORT = getenv_or_fail('REDIS_PORT', '6379') +REDIS_DB_NUMBER = os.environ.get('REDIS_DB_NUMBER', '') +REDIS_URL = "redis://{}:{}/{}".format(REDIS_HOSTNAME, REDIS_PORT, REDIS_DB_NUMBER) diff --git a/shuttleapp/settings/development.py b/shuttleapp/settings/development.py new file mode 100644 index 0000000..db0c47f --- /dev/null +++ b/shuttleapp/settings/development.py @@ -0,0 +1,8 @@ +from shuttleapp.settings.default import * + +ALLOWED_HOSTS += [ + '*', +] + +DEBUG = True +TEMPLATE_DEBUG = True diff --git a/shuttlebackoffice/urls.py b/shuttleapp/urls.py similarity index 84% rename from shuttlebackoffice/urls.py rename to shuttleapp/urls.py index 8f0d8f3..0ab4950 100644 --- a/shuttlebackoffice/urls.py +++ b/shuttleapp/urls.py @@ -1,4 +1,4 @@ -"""shuttlebackoffice URL Configuration +"""shuttleapp URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.2/topics/http/urls/ @@ -14,8 +14,10 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + + path('provider/', include('apps.providers.urls')), ] diff --git a/shuttlebackoffice/wsgi.py b/shuttleapp/wsgi.py similarity index 70% rename from shuttlebackoffice/wsgi.py rename to shuttleapp/wsgi.py index 7ba5d12..04b6e64 100644 --- a/shuttlebackoffice/wsgi.py +++ b/shuttleapp/wsgi.py @@ -1,5 +1,5 @@ """ -WSGI config for shuttlebackoffice project. +WSGI config for shuttleapp project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shuttlebackoffice.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shuttleapp.settings') application = get_wsgi_application() diff --git a/uml.png b/uml.png new file mode 100644 index 0000000..74bd7ba Binary files /dev/null and b/uml.png differ diff --git a/variables.dev b/variables.dev new file mode 100644 index 0000000..2fcbd51 --- /dev/null +++ b/variables.dev @@ -0,0 +1,12 @@ +DJANGO_SECRET_KEY='}x;#tt`a?:W;:3cqGYVC3(xB_t!tc2@P>;!_{