diff --git a/.env.sample b/.env.sample index e08632c..92af813 100644 --- a/.env.sample +++ b/.env.sample @@ -51,3 +51,5 @@ REQUESTS_TIMEOUT=30 CACHE_TTL_CURRENCY_RATES=86_400 # время актуальности данных о погоде (в секундах) CACHE_TTL_WEATHER=10_700 +# время актуальности данных о новостях (в секундах), по умолчанию - час +CACHE_TTL_NEWS=3_600 \ No newline at end of file diff --git a/README.md b/README.md index 10873b4..0ba4f13 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,114 @@ Service to get up-to-date information about countries and cities. ## Requirements: +Install the appropriate software: +1. [Docker Desktop](https://www.docker.com). +2. [Git](https://github.com/git-guides/install-git). +3. [PyCharm](https://www.jetbrains.com/ru-ru/pycharm/download) (optional). ## Installation +1. To configure the application copy `.env.sample` into `.env` file: + ```shell + cp .env.sample .env + ``` + + This file contains environment variables that will share their values across the application. + The sample file (`.env.sample`) contains a set of variables with default values. + So it can be configured depending on the environment. + + To access the API, visit the appropriate resources and obtain an access token: + - APILayer – Geography API (https://apilayer.com/marketplace/geo-api) + - OpenWeather – Weather Free Plan (https://openweathermap.org/price#weather) + - NewsAPI - API for getting news (https://newsapi.org/) + + Set received access tokens as environment variable values (in `.env` file): + - `API_KEY_APILAYER` – for APILayer access token + - `API_KEY_OPENWEATHER` – for OpenWeather access token + - `API_KEY_NEWSAPI` – for NewsAPI access token + +2. Build the container using Docker Compose: + ```shell + docker compose build + ``` + This command should be run from the root directory where `Dockerfile` is located. + You also need to build the docker container again in case if you have updated `requirements.txt`. + +3. Now it is possible to run the project inside the Docker container: + ```shell + docker compose up + ``` + When containers are up server starts at [http://0.0.0.0:8020](http://0.0.0.0:8020). You can open it in your browser. + +4. To run application correctly set up the database using commands: + Connect to the application Docker-container: + ```shell + docker compose exec countries-informer-app bash + ``` + Apply migrations to create tables in the database: + ```shell + ./manage.py migrate + ``` +## Usage +1. To get access to Django Admin panel you need to add superuser. + Connect to the application Docker-container (if you are outside the container): + ```shell + docker compose exec app bash + ``` + Create superuser: + ```shell + ./manage.py createsuperuser + ``` +2. Go to [http://0.0.0.0:8000/admin](http://0.0.0.0:8000/admin) and manage your jobs and blog posts. -## Usage +## Automation commands +The project contains a special `Makefile` that provides shortcuts for a set of commands: +1. Build the Docker container: + ```shell + make build + ``` +2. Generate Sphinx documentation run: + ```shell + make docs-html + ``` -## Automation commands +3. Autoformat source code: + ```shell + make format + ``` +4. Static analysis (linters): + ```shell + make lint + ``` + +5. Autotests: + ```shell + make test + ``` + +6. Run autoformat, linters and tests in one command: + ```shell + make all + ``` + +Run these commands from the source directory where `Makefile` is located. ## Documentation +The project integrated with the [Sphinx](https://www.sphinx-doc.org/en/master/) documentation engine. +It allows the creation of documentation from source code. +So the source code should contain docstrings in [reStructuredText](https://docutils.sourceforge.io/rst.html) format. + +To create HTML documentation run this command from the source directory where `Makefile` is located: +```shell +make docs-html +``` ## Contributing @@ -28,4 +120,4 @@ Pull requests are welcome. For major changes, please open an issue first to disc Please make sure to update tests as appropriate. ## License -[MIT](https://choosealicense.com/licenses/mit/) +[MIT](https://choosealicense.com/licenses/mit/) \ No newline at end of file diff --git a/src/app/settings.py b/src/app/settings.py index 0035723..195bbee 100644 --- a/src/app/settings.py +++ b/src/app/settings.py @@ -162,9 +162,12 @@ CACHE_TTL_CURRENCY_RATES: int = int(os.getenv("CACHE_TTL_CURRENCY_RATES", "86_400")) # время актуальности данных о погоде (в секундах), по умолчанию ~ три часа CACHE_TTL_WEATHER: int = int(os.getenv("CACHE_TTL_WEATHER", "10_700")) +# время актуальности данных о новостях (в секундах), по умолчанию - час +CACHE_TTL_NEWS: int = int(os.getenv("CACHE_TTL_WEATHER", "3_600")) CACHE_WEATHER = "cache_weather" CACHE_CURRENCY = "cache_currency" +CACHE_NEWS = "cache_news" CACHES = { # общий кэш приложения "default": { @@ -188,6 +191,14 @@ "OPTIONS": {"db": "2"}, "TIMEOUT": CACHE_TTL_CURRENCY_RATES, }, + # кэширование данных о новостях + CACHE_NEWS: { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": BROKER_URL, + "KEY_PREFIX": "news", + "OPTIONS": {"db": "3"}, + "TIMEOUT": CACHE_TTL_NEWS, + }, } # настройки для Celery @@ -215,3 +226,28 @@ API_KEY_NEWSAPI = env("API_KEY_NEWSAPI") # таймаут запросов на внешние ресурсы REQUESTS_TIMEOUT = env.int("REQUESTS_TIMEOUT") + + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", +} + +if DEBUG: + INSTALLED_APPS += [ + "debug_toolbar", + ] + MIDDLEWARE += [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + ] + INTERNAL_IPS = [ + "127.0.0.1", + "localhost", + "172.20.0.6", + ] + DEBUG_TOOLBAR_CONFIG = { + "SHOW_TOOLBAR_CALLBACK": lambda request: True, + } + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ] \ No newline at end of file diff --git a/src/app/urls.py b/src/app/urls.py index 5a5939e..d555402 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/v1/", include("geo.urls")), + path("api/v1/", include("news.urls")), re_path( r"^swagger(?P\.json|\.yaml)$", schema_view.without_ui(cache_timeout=0), diff --git a/src/geo/clients/currency.py b/src/geo/clients/currency.py new file mode 100644 index 0000000..0eb431f --- /dev/null +++ b/src/geo/clients/currency.py @@ -0,0 +1,41 @@ +""" +Функции для взаимодействия с внешним сервисом-провайдером данных о курсах валют. +""" +from http import HTTPStatus +from typing import Optional + +import httpx + +from app.settings import REQUESTS_TIMEOUT, API_KEY_APILAYER +from base.clients.base import BaseClient + + +class CurrencyClient(BaseClient): + """ + Реализация функций для взаимодействия с внешним сервисом-провайдером данных о курсах валют. + """ + + def get_base_url(self) -> str: + return "https://api.apilayer.com/fixer/latest" + + def _request(self, endpoint: str) -> Optional[dict]: + + # формирование заголовков запроса + headers = {"apikey": API_KEY_APILAYER} + + with httpx.Client(timeout=REQUESTS_TIMEOUT) as client: + # получение ответа + response = client.get(endpoint, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json() + + return None + + def get_rates(self, base: str = "rub") -> Optional[dict]: + """ + Получение данных о курсах валют. + :param base: Базовая валюта + :return: Ответ от API в формате dict + """ + + return self._request(f"{self.get_base_url()}?base={base}") \ No newline at end of file diff --git a/src/geo/clients/shemas.py b/src/geo/clients/shemas.py index 76a9388..88f2e19 100644 --- a/src/geo/clients/shemas.py +++ b/src/geo/clients/shemas.py @@ -184,6 +184,7 @@ class WeatherInfoDTO(BaseModel): temp: float pressure: int humidity: int + visibility: int wind_speed: float description: str diff --git a/src/geo/serializers.py b/src/geo/serializers.py index e4a3a08..62805d9 100644 --- a/src/geo/serializers.py +++ b/src/geo/serializers.py @@ -1,3 +1,4 @@ +"""Сериализаторы приложения geo""" from rest_framework import serializers from geo.models import Country, City @@ -47,3 +48,26 @@ class Meta: "longitude", "country", ] + + +class WeatherSerializer(serializers.Serializer): + """ + Сериализатор данных о погоде. + """ + + temp = serializers.FloatField() + pressure = serializers.IntegerField() + humidity = serializers.IntegerField() + visibility = serializers.IntegerField() + wind_speed = serializers.FloatField() + description = serializers.CharField() + + +class CurrencySerializer(serializers.Serializer): + """ + Cериализатор данных о валюте. + """ + + base = serializers.CharField() + date = serializers.DateField() + rates = serializers.DictField() \ No newline at end of file diff --git a/src/geo/services/currency.py b/src/geo/services/currency.py new file mode 100644 index 0000000..c573b96 --- /dev/null +++ b/src/geo/services/currency.py @@ -0,0 +1,29 @@ +"""Сервисный слой для работы с данными о курсах валют.""" +from typing import Optional + +from geo.clients.currency import CurrencyClient +from geo.clients.shemas import CurrencyRatesDTO + + +class CurrencyService: + """ + Сервис для работы с данными о курсах валют. + """ + + def get_currency(self, base: str) -> Optional[CurrencyRatesDTO]: + """ + Получение списка курсов валют по её коду. + + :param base: Базовая валюта + :return: + """ + + data = CurrencyClient().get_rates(base) + if data: + return CurrencyRatesDTO( + base=data["base"], + date=data["date"], + rates=data["rates"], + ) + + return None \ No newline at end of file diff --git a/src/geo/services/weather.py b/src/geo/services/weather.py index d9fefd3..283c1f0 100644 --- a/src/geo/services/weather.py +++ b/src/geo/services/weather.py @@ -1,8 +1,8 @@ +"""Сервисный слой для работы с данными о погоде.""" from typing import Optional -from geo.clients.shemas import CountryDTO +from geo.clients.shemas import WeatherInfoDTO from geo.clients.weather import WeatherClient -from geo.models import Country class WeatherService: @@ -10,7 +10,7 @@ class WeatherService: Сервис для работы с данными о погоде. """ - def get_weather(self, alpha2code: str, city: str) -> Optional[dict]: + def get_weather(self, alpha2code: str, city: str) -> Optional[WeatherInfoDTO]: """ Получение списка стран по названию. @@ -19,33 +19,15 @@ def get_weather(self, alpha2code: str, city: str) -> Optional[dict]: :return: """ - if data := WeatherClient().get_weather(f"{city},{alpha2code}"): - return data - - return None - - def build_model(self, country: CountryDTO) -> Country: - """ - Формирование объекта модели страны. - - :param CountryDTO country: Данные о стране. - :return: - """ - - return Country( - alpha3code=country.alpha3code, - name=country.name, - alpha2code=country.alpha2code, - capital=country.capital, - region=country.region, - subregion=country.subregion, - population=country.population, - latitude=country.latitude, - longitude=country.longitude, - demonym=country.demonym, - area=country.area, - numeric_code=country.numeric_code, - flag=country.flag, - currencies=[currency.code for currency in country.currencies], - languages=[language.name for language in country.languages], - ) + data = WeatherClient().get_weather(f"{city},{alpha2code}") + if data: + return WeatherInfoDTO( + temp=data["main"]["temp"], + pressure=data["main"]["pressure"], + humidity=data["main"]["humidity"], + visibility=data["visibility"], + wind_speed=data["wind"]["speed"], + description=data["weather"][0]["description"], + ) + + return None \ No newline at end of file diff --git a/src/geo/urls.py b/src/geo/urls.py index 7c95bde..ee3ad5c 100644 --- a/src/geo/urls.py +++ b/src/geo/urls.py @@ -1,6 +1,14 @@ -from django.urls import path +from django.conf import settings +from django.urls import path, include -from geo.views import get_city, get_cities, get_countries, get_country, get_weather +from geo.views import ( + get_city, + get_cities, + get_countries, + get_country, + get_weather, + get_currency +) urlpatterns = [ path("city", get_cities, name="cities"), @@ -8,4 +16,12 @@ path("country", get_countries, name="countries"), path("country/", get_country, name="country"), path("weather//", get_weather, name="weather"), + path("currency/", get_currency, name="currency"), ] + +if settings.DEBUG: + import debug_toolbar + + urlpatterns += [ + path("__debug__/", include(debug_toolbar.urls)), + ] \ No newline at end of file diff --git a/src/geo/views.py b/src/geo/views.py index d85d138..e5aab9b 100644 --- a/src/geo/views.py +++ b/src/geo/views.py @@ -1,20 +1,29 @@ """Представления Django""" import re -from typing import Any from django.core.cache import caches from django.http import JsonResponse from rest_framework.decorators import api_view from rest_framework.exceptions import NotFound, ValidationError from rest_framework.request import Request - -from app.settings import CACHE_WEATHER -from geo.serializers import CountrySerializer, CitySerializer +from rest_framework.settings import api_settings + +from app.settings import CACHE_WEATHER, CACHE_CURRENCY +from geo.serializers import ( + CountrySerializer, + CitySerializer, + WeatherSerializer, + CurrencySerializer, +) from geo.services.city import CityService from geo.services.country import CountryService +from geo.services.currency import CurrencyService from geo.services.shemas import CountryCityDTO from geo.services.weather import WeatherService +paginator = api_settings.DEFAULT_PAGINATION_CLASS() +paginator.page_size = 2 + @api_view(["GET"]) def get_city(request: Request, name: str) -> JsonResponse: @@ -30,7 +39,8 @@ def get_city(request: Request, name: str) -> JsonResponse: """ if cities := CityService().get_cities(name): - serializer = CitySerializer(cities, many=True) + result_page = paginator.paginate_queryset(cities, request) + serializer = CitySerializer(result_page, many=True) return JsonResponse(serializer.data, safe=False) @@ -64,7 +74,8 @@ def get_cities(request: Request) -> JsonResponse: ) if cities := CityService().get_cities_by_codes(codes_set): - serializer = CitySerializer(cities, many=True) + result_page = paginator.paginate_queryset(cities, request) + serializer = CitySerializer(result_page, many=True) return JsonResponse(serializer.data, safe=False) @@ -85,7 +96,8 @@ def get_country(request: Request, name: str) -> JsonResponse: """ if countries := CountryService().get_countries(name): - serializer = CountrySerializer(countries, many=True) + result_page = paginator.paginate_queryset(countries, request) + serializer = CountrySerializer(result_page, many=True) return JsonResponse(serializer.data, safe=False) @@ -111,7 +123,8 @@ def get_countries(request: Request) -> JsonResponse: ) if countries := CountryService().get_countries_by_codes(codes_set): - serializer = CountrySerializer(countries, many=True) + result_page = paginator.paginate_queryset(countries, request) + serializer = CountrySerializer(result_page, many=True) return JsonResponse(serializer.data, safe=False) @@ -136,11 +149,29 @@ def get_weather(request: Request, alpha2code: str, city: str) -> JsonResponse: caches[CACHE_WEATHER].set(cache_key, data) if data: - return JsonResponse(data) - + serializer = WeatherSerializer(data) + return JsonResponse(serializer.data, safe=False) raise NotFound @api_view(["GET"]) -def get_currency(*args: Any, **kwargs: Any) -> None: - pass +def get_currency(request: Request, currency: str) -> JsonResponse: + """ + Получение информации о погоде в указанном городе. + + :param Request request: Объект запроса + :param str currency: базовая валюта + :return: + """ + + cache_key = f"currency_{currency}_info" + data = caches[CACHE_CURRENCY].get(cache_key) + if not data: + if data := CurrencyService().get_currency(base=currency): + caches[CACHE_CURRENCY].set(cache_key, data) + + if data: + serializer = CurrencySerializer(data) + return JsonResponse(serializer.data, safe=False) + + raise NotFound \ No newline at end of file diff --git a/src/news/serializers.py b/src/news/serializers.py new file mode 100644 index 0000000..47f915d --- /dev/null +++ b/src/news/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + + +class NewsSerializer(serializers.Serializer): + """ + Сериализатор для модели данных новости. + """ + + source = serializers.CharField() + author = serializers.CharField(allow_null=True) + title = serializers.CharField() + description = serializers.CharField(allow_null=True) + url = serializers.CharField(allow_null=True) + published_at = serializers.DateTimeField() \ No newline at end of file diff --git a/src/news/urls.py b/src/news/urls.py new file mode 100644 index 0000000..5377a62 --- /dev/null +++ b/src/news/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from news.views import get_news + +urlpatterns = [ + path("news/", get_news, name="news"), +] \ No newline at end of file diff --git a/src/news/views.py b/src/news/views.py new file mode 100644 index 0000000..8d05057 --- /dev/null +++ b/src/news/views.py @@ -0,0 +1,37 @@ +"""Представления Django""" +from django.core.cache import caches +from django.http import JsonResponse +from rest_framework.decorators import api_view +from rest_framework.request import Request +from rest_framework.settings import api_settings + +from app.settings import CACHE_NEWS +from news.serializers import NewsSerializer +from news.services.news import NewsService + +paginator = api_settings.DEFAULT_PAGINATION_CLASS() +paginator.page_size = 5 + + +@api_view(["GET"]) +def get_news(request: Request, alpha2code: str) -> JsonResponse: + """ + Получение информации о новостях страны. + :param Request request: Объект запроса + :param str alpha2code: ISO Alpha2 код страны + :return: + """ + cache_key = f"news_{alpha2code}" + + data = caches[CACHE_NEWS].get(cache_key) + if not data: + if data := NewsService().get_news(alpha2code): + caches[CACHE_NEWS].set(cache_key, data) + + if data: + result_page = paginator.paginate_queryset(data, request) + serializer = NewsSerializer(result_page, many=True) + + return JsonResponse(serializer.data, safe=False) + + return JsonResponse([], safe=False) \ No newline at end of file