diff --git a/README.md b/README.md index 10873b4..6ad6f08 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,113 @@ 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). + +## Quick start +1. `cp .env.sample .env` +2. `docker compose up --build` +3. `docker exec -it countries-informer-app bash` +4. `python manage.py migrate --fake sessions zero` + `python manage.py showmigrations + sessions + [ ] 0001_initial` + `python manage.py migrate --fake-initial` ## Installation +Clone the repository to your computer: +```bash +git clone https://github.com/DmitryZubarev/HSE-Python-CountriesInformer.git +``` +1. To configure the application copy `.env.sample` into `.env` file: + ```shell + cp .env.sample .env + ``` -## Usage + 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. + +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. + + +## Usage +1. To manage your api 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:8020/admin](http://0.0.0.0:8020/admin) and manage your database. ## 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 + ``` + +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 +``` +After generation documentation can be opened from a file `docs/build/html/index.html`. ## Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. @@ -28,4 +118,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/docs/source/code.rst b/docs/source/code.rst index 39fae9a..d67ca74 100644 --- a/docs/source/code.rst +++ b/docs/source/code.rst @@ -1,5 +1,94 @@ +Документация к исходному коду +============================= + .. autosummary:: :toctree: _autosummary :recursive: app + +.. index:: view, base + +Geo service +=========== +.. toctree:: + :maxdepth: 2 + :caption: Содержимое: + +Клиенты +------- +Страны и города +^^^^^^^^^^^^^^^ +.. automodule:: geo.clients.geo + :members: + +Валюты +^^^^^^ +.. automodule:: geo.clients.currency + :members: + +Погода +^^^^^^ +.. automodule:: geo.clients.weather + :members: + +Схемы +^^^^^ +.. automodule:: geo.clients.schemas + :members: + +Сервисы +------- + +Страны +^^^^^^^^^^^^^^^ +.. automodule:: geo.services.country + :members: + +Города +^^^^^^^^^^^^^^^ +.. automodule:: geo.services.city + :members: + +Валюты +^^^^^^ +.. automodule:: geo.services.currency + :members: + +Погода +^^^^^^ +.. automodule:: geo.services.weather + :members: + +Схемы +^^^^^ +.. automodule:: geo.services.schemas + :members: + +Views +------ +.. automodule:: geo.views + :members: + +.. index:: view, base + +News service +============ +.. toctree:: + :maxdepth: 2 + :caption: Содержимое: + +Клиент +------- +.. automodule:: news.clients.news + :members: + +Сервис +------- +.. automodule:: news.services.news + :members: + +Views +------ +.. automodule:: news.views + :members: \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index a9cae5a..10896b9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,3 +1,9 @@ +.. toctree:: + :maxdepth: 1 + :hidden: + + API reference + Портфолио ========= @@ -6,29 +12,131 @@ Зависимости =========== +1. Фреймворк +Django>=4.0.7,<4.1.0 +djangorestframework>=3.14.0,<3.15.0 + +2. Генерация Swagger +drf-yasg>=1.21.4,<1.22.0 + +3. Работа с переменными окружения +django-environ>=0.9.0,<0.10.0 + +4. Отладчик для Django +django-debug-toolbar>=3.6.0,<3.7.0 + +5. Адаптер для подключения к PostgreSQL +psycopg2-binary>=2.9.3,<2.10.0 + +6. Документация +Sphinx>=5.1.1,<5.2.0 +sphinx-rtd-theme>=1.0.0,<2.0.0 + +7. Статический анализ кода (линтеры) +isort>=5.10.1,<5.11.0 +flake8-django>=1.1.5,<1.2.0 +pylint-django>=2.5.3,<2.6.0 +django-stubs[compatible-mypy]>=1.12.0,<1.13.0 + +8. Автоматическое форматирование кода +black>=22.8.0,<22.9.0 + +9. Redis +redis>=4.3.4,<4.4.0 + +10. Распределенная очередь задач +celery>=5.2.7,<5.3.0 +django-celery-beat>=2.3.0,<2.4.0 + +11. Работа с RabbitMQ +pika>=1.3.1,<1.4.0 + +12. Работа с HTTP-запросами +httpx>=0.23.0,<0.24.0 + +13. DTO и валидация данных +pydantic>=1.10.2,<1.11.0 Установка ========= +Установите требуемое ПО: + +1. Docker для контейнеризации – |link_docker| + +.. |link_docker| raw:: html + + Docker Desktop + +2. Для работы с системой контроля версий – |link_git| + +.. |link_git| raw:: html + + Git + +3. IDE для работы с исходным кодом – |link_pycharm| + +.. |link_pycharm| raw:: html + + PyCharm + +Клонируйте репозиторий проекта в свою рабочую директорию: + + .. code-block:: console + git clone https://github.com/miamib34ch/HSE-Python-CountriesInformer.git Использование ============= +1. Чтобы управлять вашим API, вам необходимо добавить суперпользователя. Подключитесь к контейнеру Docker-приложения (если вы находитесь вне контейнера): + + .. code-block:: console + docker-compose exec app bash + +2. Создайте суперпользователя: -Работа с базой данных ---------------------- + .. code-block:: console + ./manage.py createsuperuser +3. Перейдите по адресу http://0.0.0.0:8020/admin и управляйте вашей базой данных. Автоматизация ============= +Проект содержит специальный файл Makefile, который предоставляет ярлыки для выполнения определенного набора команд: + +Построить Docker-контейнер: + + .. code-block:: console + make build + +Сгенерировать документацию Sphinx: + + .. code-block:: console + make docs-html + +Автоформатирование исходного кода: + + .. code-block:: console + make format + +Статический анализ (линтеры): + + .. code-block:: console + make lint + +Автотесты: + .. code-block:: console + make test -Тестирование -============ +Выполнить автоформатирование, статический анализ и тесты одной командой: + .. code-block:: console + make all +Запустите эти команды из директории с исходным кодом, где расположен файл Makefile. \ No newline at end of file diff --git a/src/app/settings.py b/src/app/settings.py index 0035723..a04225c 100644 --- a/src/app/settings.py +++ b/src/app/settings.py @@ -76,6 +76,11 @@ WSGI_APPLICATION = "app.wsgi.application" +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 1, +} + # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases @@ -162,9 +167,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_NEWS", "3_600")) CACHE_WEATHER = "cache_weather" CACHE_CURRENCY = "cache_currency" +CACHE_NEWS = "cache_news" CACHES = { # общий кэш приложения "default": { @@ -188,6 +196,13 @@ "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 @@ -214,4 +229,4 @@ # токен доступа к API для получения последних новостей API_KEY_NEWSAPI = env("API_KEY_NEWSAPI") # таймаут запросов на внешние ресурсы -REQUESTS_TIMEOUT = env.int("REQUESTS_TIMEOUT") +REQUESTS_TIMEOUT = env.int("REQUESTS_TIMEOUT") \ 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/admin.py b/src/geo/admin.py index 4ed787c..838a76a 100644 --- a/src/geo/admin.py +++ b/src/geo/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from geo.models import Country, City +from geo.models import Country, City, Weather, CurrencyRates, Currency @admin.register(Country) @@ -40,3 +40,61 @@ class CityAdmin(admin.ModelAdmin): "created_at", "updated_at", ) + + +@admin.register(Weather) +class WeatherAdmin(admin.ModelAdmin): + list_display = ( + "city", + "temp", + "pressure", + "humidity", + "wind_speed", + "description", + "visibility", + "dt", + "timezone", + "created_at", + "updated_at", + ) + + search_fields = ("city", "temp") + + list_filter = ( + "created_at", + "updated_at", + ) + + +@admin.register(Currency) +class CurrencyAdmin(admin.ModelAdmin): + list_display = ( + "base", + "date", + "created_at", + "updated_at", + ) + + search_fields = ("base", "date") + + list_filter = ( + "created_at", + "updated_at", + ) + + +@admin.register(CurrencyRates) +class CurrencyRatesAdmin(admin.ModelAdmin): + list_display = ( + "currency_name", + "rate", + "created_at", + "updated_at", + ) + + search_fields = ("currency_name", "rate") + + list_filter = ( + "created_at", + "updated_at", + ) diff --git a/src/geo/clients/currency.py b/src/geo/clients/currency.py new file mode 100644 index 0000000..5aae440 --- /dev/null +++ b/src/geo/clients/currency.py @@ -0,0 +1,48 @@ +""" +Функции для взаимодействия с внешним сервисом-провайдером данных о курсах валют. +""" +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 +from geo.clients.shemas import CurrencyRatesDTO + + +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") -> CurrencyRatesDTO | None: + """ + Получение данных о курсах валют. + + :param base: Базовая валюта + :return: + """ + + data = self._request(f"{self.get_base_url()}?base={base}") + if not data: + return None + return CurrencyRatesDTO( + base=data["base"], date=data["date"], rates=data["rates"] + ) \ No newline at end of file diff --git a/src/geo/clients/shemas.py b/src/geo/clients/shemas.py index 76a9388..c479983 100644 --- a/src/geo/clients/shemas.py +++ b/src/geo/clients/shemas.py @@ -178,6 +178,9 @@ class WeatherInfoDTO(BaseModel): humidity=54, wind_speed=4.63, description="scattered clouds", + visibility=100, + dt=datetime.datetime(2024, 3, 3, 3, 1), + timezone=1300 ) """ @@ -186,6 +189,9 @@ class WeatherInfoDTO(BaseModel): humidity: int wind_speed: float description: str + visibility: int + dt: datetime + timezone: int class LocationInfoDTO(BaseModel): diff --git a/src/geo/clients/weather.py b/src/geo/clients/weather.py index 6496ad9..bf135e4 100644 --- a/src/geo/clients/weather.py +++ b/src/geo/clients/weather.py @@ -8,6 +8,7 @@ from app.settings import REQUESTS_TIMEOUT, API_KEY_OPENWEATHER from base.clients.base import BaseClient +from geo.clients.shemas import WeatherInfoDTO class WeatherClient(BaseClient): @@ -27,7 +28,7 @@ def _request(self, endpoint: str) -> Optional[dict]: return None - def get_weather(self, location: str) -> Optional[dict]: + def get_weather(self, location: str) -> Optional[WeatherInfoDTO]: """ Получение данных о погоде. @@ -35,6 +36,21 @@ def get_weather(self, location: str) -> Optional[dict]: :return: """ - return self._request( + data = self._request( f"{self.get_base_url()}?units=metric&q={location}&appid={API_KEY_OPENWEATHER}" ) + + return ( + WeatherInfoDTO( + temp=data["main"]["temp"], + pressure=data["main"]["pressure"], + humidity=data["main"]["humidity"], + wind_speed=data["wind"]["speed"], + description=data["weather"][0]["description"], + visibility=data["visibility"], + dt=data["dt"], + timezone=data["timezone"] // 3600, + ) + if data + else None + ) diff --git a/src/geo/models.py b/src/geo/models.py index f190dab..e045b56 100644 --- a/src/geo/models.py +++ b/src/geo/models.py @@ -96,3 +96,49 @@ class Meta: verbose_name = "Город" verbose_name_plural = "Города" ordering = ["name"] + +class Currency(TimeStampMixin): + base = models.CharField(verbose_name="Название валюты", max_length=255) + date = models.DateTimeField(verbose_name="Дата проверки валюты") + + class Meta: + verbose_name = "Валюта" + + +class CurrencyRates(TimeStampMixin): + currency = models.ForeignKey( + Currency, + on_delete=models.PROTECT, + related_name="currency", + verbose_name="Валюта", + ) + currency_name = models.CharField( + verbose_name="Валюта для сравнения", max_length=255 + ) + rate = models.FloatField(verbose_name="Отношение валют") + + +class Weather(TimeStampMixin): + """Модель погоды""" + + city = models.ForeignKey( + City, + on_delete=models.PROTECT, + related_name="city", + verbose_name="Город", + ) + temp = models.FloatField(verbose_name="Температура") + pressure = models.IntegerField(verbose_name="Давление") + humidity = models.IntegerField(verbose_name="Влажность") + wind_speed = models.FloatField(verbose_name="Скорость ветра") + description = models.CharField(verbose_name="Описание погоды", max_length=255) + visibility = models.IntegerField(verbose_name="Видимость") + dt = models.DateTimeField(verbose_name="Время") + timezone = models.IntegerField(verbose_name="Временная зона") + + def __str__(self) -> str: + return f"{self.temp=} {self.pressure=}" + + class Meta: + verbose_name = "Погода" + verbose_name_plural = "Погода" diff --git a/src/geo/serializers.py b/src/geo/serializers.py index e4a3a08..ebb99d9 100644 --- a/src/geo/serializers.py +++ b/src/geo/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from geo.models import Country, City +from geo.models import Country, City, Weather, Currency, CurrencyRates class CountrySerializer(serializers.ModelSerializer): @@ -47,3 +47,51 @@ class Meta: "longitude", "country", ] + +class WeatherSerializer(serializers.ModelSerializer): + """ + Сериализатор для данных о погоде. + """ + + city = CitySerializer(read_only=True) + + class Meta: + model = Weather + fields = [ + "id", + "temp", + "pressure", + "humidity", + "wind_speed", + "description", + "visibility", + "dt", + "timezone", + "city", + ] + + +class CurrencySerializer(serializers.ModelSerializer): + """ + Сериализатор для данных о валюте. + """ + + class Meta: + model = Currency + fields = [ + "id", + "base", + "date", + ] + + +class CurrencyRatesSerializer(serializers.ModelSerializer): + """ + Сериализатор для данных о курсе валют. + """ + + currency = CurrencySerializer(read_only=True) + + class Meta: + model = CurrencyRates + fields = ["id", "currency_name", "rate", "currency"] diff --git a/src/geo/services/currency.py b/src/geo/services/currency.py new file mode 100644 index 0000000..3675b15 --- /dev/null +++ b/src/geo/services/currency.py @@ -0,0 +1,71 @@ +from django.db.models import Q, QuerySet + +from geo.clients.currency import CurrencyClient +from geo.clients.shemas import CurrencyRatesDTO +from geo.models import Currency, CurrencyRates + + +class CurrencyService: + """ + Сервис для работы с данными о валютах. + """ + + def get_currency(self, currency_base: str) -> QuerySet[CurrencyRates]: + """ + Получение валюты по названию. + :param str currency_base: название валюты + :return: + """ + + currency_rates = CurrencyRates.objects.filter( + Q(currency__base__contains=currency_base) + ) + if not currency_rates: + if currency_data := CurrencyClient().get_rates(currency_base): + currency = Currency.objects.create( + base=currency_data.base, + date=currency_data.date, + ) + CurrencyRates.objects.bulk_create( + [ + self.build_model_rates(currency, name, rate) + for name, rate in currency_data.rates.items() + ], + batch_size=1000, + ) + currency_rates = CurrencyRates.objects.filter( + Q(currency__base__contains=currency.base) + ) + return currency_rates + + def build_model_rates( + self, currency: Currency, name: str, rate: float + ) -> CurrencyRates: + """ + Формирование объекта модели значения отношений валют. + + :param Currency currency: Валюта + :param str name: называние валюты + :param float rate: отношение валют + + :return: + """ + return CurrencyRates( + currency=currency, + currency_name=name, + rate=rate, + ) + + def build_model(self, currency: CurrencyRatesDTO) -> Currency: + """ + Формирование объекта модели валюты. + + :param CurrencyRatesDTO currency: Данные о валюте. + + :return: + """ + + return Currency( + base=currency.base, + date=currency.date, + ) \ No newline at end of file diff --git a/src/geo/services/weather.py b/src/geo/services/weather.py index d9fefd3..a26e736 100644 --- a/src/geo/services/weather.py +++ b/src/geo/services/weather.py @@ -1,8 +1,10 @@ from typing import Optional +from django.db.models import Q -from geo.clients.shemas import CountryDTO +from geo.clients.shemas import WeatherInfoDTO from geo.clients.weather import WeatherClient -from geo.models import Country +from geo.models import Weather +from geo.services.city import CityService class WeatherService: @@ -10,42 +12,45 @@ class WeatherService: Сервис для работы с данными о погоде. """ - def get_weather(self, alpha2code: str, city: str) -> Optional[dict]: + def get_weather(self, alpha2code: str, city: str) -> Optional[Weather]: """ - Получение списка стран по названию. + Получение погоды по стране и городу. :param alpha2code: ISO Alpha2 код страны :param city: Город :return: """ - if data := WeatherClient().get_weather(f"{city},{alpha2code}"): - return data + weather = Weather.objects.filter( + Q(city__name__contains=city) + | Q(city__country__alpha2code__contains=alpha2code) + ) + if not weather: + if weather_data := WeatherClient().get_weather(f"{city},{alpha2code}"): + weather_object = self.build_model(weather_data, city) + return weather_object - return None + return weather.first() - def build_model(self, country: CountryDTO) -> Country: + def build_model(self, weather: WeatherInfoDTO, city_name: str) -> Weather: """ - Формирование объекта модели страны. + Формирование объекта модели погоды. - :param CountryDTO country: Данные о стране. + :param WeatherInfoDTO weather: Данные о погоде. + :param str city_name: Город :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], + city = CityService().get_cities(city_name)[:1][0] + weather_object = Weather.objects.create( + city=city, + temp=weather.temp, + pressure=weather.pressure, + humidity=weather.humidity, + wind_speed=weather.wind_speed, + description=weather.description, + visibility=weather.visibility, + dt=weather.dt, + timezone=weather.timezone, ) + return weather_object \ No newline at end of file diff --git a/src/geo/tests.py b/src/geo/tests.py new file mode 100644 index 0000000..9e22e0f --- /dev/null +++ b/src/geo/tests.py @@ -0,0 +1,132 @@ +from datetime import datetime + +from django.urls import reverse +from rest_framework.test import APITestCase + +from geo.models import City, Country, Currency, CurrencyRates, Weather + + +class CountryTestCase(APITestCase): + """ + Тесты для сервиса стран. + """ + + def setUp(self) -> None: + """ + Настройка перед тестированием. + :return: + """ + self.country = Country.objects.create( + name="test", + alpha2code="te", + alpha3code="tes", + capital="test", + region="test", + subregion="test", + population=1, + latitude=1, + longitude=2, + demonym="test", + area=1, + numeric_code=123, + flag="test", + currencies=[], + languages=[], + ) + self.city = City.objects.create( + country=self.country, + name="test", + region="test", + latitude=1, + longitude=1, + ) + self.weather = Weather.objects.create( + city=self.city, + temp=0, + pressure=0, + humidity=0, + wind_speed=0, + description="test", + visibility=1, + dt=datetime.now().astimezone(), + timezone=1, + ) + self.currency = Currency.objects.create( + base="test", date=datetime.now().astimezone() + ) + self.currency_rates_first = CurrencyRates.objects.create( + currency=self.currency, currency_name="test", rate=2.0 + ) + self.currency_rates_second = CurrencyRates.objects.create( + currency=self.currency, currency_name="test2", rate=0.5 + ) + + def test_get_city(self) -> None: + """ + Тест получения списка городов. + :return: + """ + response = self.client.get(reverse("cities"), {"codes": "te,test"}) + data = response.json()["results"] + self.assertEqual(len(data), 1) + item = data[0] + self.assertEqual(item["name"], self.city.name) + self.assertEqual(item["region"], self.city.region) + + def test_get_one_city(self) -> None: + """ + Тест получения одного города. + :return: + """ + response = self.client.get(reverse("city", kwargs={"name": "test"})) + data = response.json()["results"] + item = data[0] + self.assertEqual(item["name"], self.city.name) + + def test_get_countries(self) -> None: + """ + Тест получения списка стран. + :return: + """ + response = self.client.get(reverse("countries"), {"codes": "te"}) + data = response.json()["results"] + self.assertEqual(len(data), 1) + item = data[0] + self.assertEqual(item["name"], self.country.name) + + def test_get_one_countries(self) -> None: + """ + Тест получения одной страны. + :return: + """ + response = self.client.get(reverse("country", kwargs={"name": "test"})) + data = response.json()["results"][0] + self.assertEqual(data["name"], self.country.name) + + def test_get_weather(self) -> None: + """ + Тест получения погоды. + :return: + """ + response = self.client.get( + reverse("weather", kwargs={"alpha2code": "te", "city": "test"}) + ) + item = response.json() + self.assertEqual(item["temp"], self.weather.temp) + self.assertEqual(item["pressure"], self.weather.pressure) + self.assertEqual(item["humidity"], self.weather.humidity) + self.assertEqual(item["wind_speed"], self.weather.wind_speed) + self.assertEqual(item["description"], self.weather.description) + + def test_get_currency(self) -> None: + """ + Тест получения валюты. + :return: + """ + data = self.client.get( + reverse("currency", kwargs={"currency_base": "test"}) + ).json()["results"] + self.assertEqual(len(data), 1) + item = data[0] + self.assertEqual(item["currency_name"], self.currency_rates_first.currency_name) + self.assertEqual(item["rate"], self.currency_rates_first.rate) diff --git a/src/geo/urls.py b/src/geo/urls.py index 7c95bde..1e86289 100644 --- a/src/geo/urls.py +++ b/src/geo/urls.py @@ -1,6 +1,13 @@ from django.urls import path -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 +15,5 @@ path("country", get_countries, name="countries"), path("country/", get_country, name="country"), path("weather//", get_weather, name="weather"), -] + path("currency/", get_currency, name="currency"), +] \ No newline at end of file diff --git a/src/geo/views.py b/src/geo/views.py index d85d138..68255c9 100644 --- a/src/geo/views.py +++ b/src/geo/views.py @@ -1,19 +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, + CurrencyRatesSerializer, +) from geo.services.city import CityService from geo.services.country import CountryService from geo.services.shemas import CountryCityDTO from geo.services.weather import WeatherService +from geo.services.currency import CurrencyService + + +pagination_class = api_settings.DEFAULT_PAGINATION_CLASS +paginator = pagination_class() @api_view(["GET"]) @@ -30,9 +40,11 @@ def get_city(request: Request, name: str) -> JsonResponse: """ if cities := CityService().get_cities(name): - serializer = CitySerializer(cities, many=True) + page = paginator.paginate_queryset(cities, request) - return JsonResponse(serializer.data, safe=False) + serializer = CitySerializer(page, many=True) + + return paginator.get_paginated_response(serializer.data) raise NotFound @@ -64,9 +76,11 @@ def get_cities(request: Request) -> JsonResponse: ) if cities := CityService().get_cities_by_codes(codes_set): - serializer = CitySerializer(cities, many=True) + page = paginator.paginate_queryset(cities, request) - return JsonResponse(serializer.data, safe=False) + serializer = CitySerializer(page, many=True) + + return paginator.get_paginated_response(serializer.data) return JsonResponse([], safe=False) @@ -85,9 +99,11 @@ def get_country(request: Request, name: str) -> JsonResponse: """ if countries := CountryService().get_countries(name): - serializer = CountrySerializer(countries, many=True) + page = paginator.paginate_queryset(countries, request) - return JsonResponse(serializer.data, safe=False) + serializer = CountrySerializer(page, many=True) + + return paginator.get_paginated_response(serializer.data) raise NotFound @@ -111,9 +127,11 @@ def get_countries(request: Request) -> JsonResponse: ) if countries := CountryService().get_countries_by_codes(codes_set): - serializer = CountrySerializer(countries, many=True) + page = paginator.paginate_queryset(countries, request) - return JsonResponse(serializer.data, safe=False) + serializer = CountrySerializer(page, many=True) + + return paginator.get_paginated_response(serializer.data) return JsonResponse([], safe=False) @@ -136,11 +154,32 @@ 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, many=False) + + 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_base: str) -> JsonResponse: + """ + Получение информации о курсе валюты. + + :param Request request: Объект запроса + :param currency_base: Название валюты + """ + cache_key = f"currency_base_{currency_base}" + data = caches[CACHE_CURRENCY].get(cache_key) + if not data: + if data := CurrencyService().get_currency(currency_base): + caches[CACHE_CURRENCY].set(cache_key, data) + + if data: + page = paginator.paginate_queryset(data, request) + + serializer = CurrencyRatesSerializer(page, many=True) + + return paginator.get_paginated_response(serializer.data) + + 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..fa29875 --- /dev/null +++ b/src/news/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from geo.serializers import CountrySerializer +from news.models import News + + +class NewsSerializer(serializers.ModelSerializer): + """ + Сериализатор для данных о новостях. + """ + + country = CountrySerializer(read_only=True) + + class Meta: + model = News + fields = [ + "id", + "source", + "author", + "title", + "description", + "url", + "published_at", + "country", + ] + ordering = ("published_at",) + \ No newline at end of file diff --git a/src/news/services/news.py b/src/news/services/news.py index 40a4659..3145829 100644 --- a/src/news/services/news.py +++ b/src/news/services/news.py @@ -1,26 +1,53 @@ from typing import Optional +from django.db.models import Q + from news.clients.news import NewsClient -from news.clients.shemas import NewsItemDTO from news.models import News +from geo.models import Country +from geo.services.country import CountryService + class NewsService: """ Сервис для работы с данными о новостях. """ - def get_news(self, country_code: str) -> Optional[list[NewsItemDTO]]: + def get_news(self, country_code: str) -> Optional[list[News]]: """ Получение актуальных новостей по коду страны. :param str country_code: ISO Alpha2 код страны :return: """ + news = News.objects.filter(Q(country__alpha2code__contains=country_code)) + if not news: + if news_data := NewsClient().get_news(country_code): + # Получаем коды стран + codes = CountryService().get_countries_codes() + + # Проверяем, определены ли коды стран, или код страны отсутствует в полученных кодах + if codes is None or country_code not in codes: + # Если коды стран не определены или код страны отсутствует, вызываем метод get_countries + CountryService().get_countries(country_code) + # Снова получаем коды стран + codes = CountryService().get_countries_codes() + # Проверяем, определены ли коды стран, или код страны отсутствует в полученных кодах + if codes is None or codes.get(country_code, None) is None: + return None + + news = News.objects.bulk_create( + [ + self.build_model(news_item, codes[country_code]) + for news_item in news_data + ], + batch_size=1000, + ) - return NewsClient().get_news(country_code) + return news - def save_news(self, country_pk: int, news: list[NewsItemDTO]) -> None: + def save_news(self, country_pk: int, news: list[News]) -> None: """ Сохранение новостей в базе данных. @@ -35,21 +62,21 @@ def save_news(self, country_pk: int, news: list[NewsItemDTO]) -> None: batch_size=1000, ) - def build_model(self, news_item: NewsItemDTO, country_id: int) -> News: + def build_model(self, news_item: News, country: int) -> News: """ Формирование объекта модели новости. - :param NewsItemDTO news_item: Данные о новости - :param int country_id: Идентификатор страны в БД + :param News news_item: Данные о новости + :param Country country: Страна в БД :return: """ return News( - country_id=country_id, + country=Country.objects.get(pk=country), source=news_item.source, author=news_item.author if news_item.author else "", title=news_item.title, description=news_item.description if news_item.description else "", url=news_item.url if news_item.url else "", published_at=news_item.published_at, - ) + ) \ No newline at end of file diff --git a/src/news/tests.py b/src/news/tests.py new file mode 100644 index 0000000..fce633d --- /dev/null +++ b/src/news/tests.py @@ -0,0 +1,68 @@ +from datetime import datetime + +from django.urls import reverse +from rest_framework.test import APITestCase + +from geo.models import Country +from news.models import News + + +class NewsTestCase(APITestCase): + """ + Тесты для новостей. + """ + + def setUp(self) -> None: + """ + Подготовка данных для тестов. + :return: + """ + country = Country.objects.create( + name="test", + alpha2code="te", + alpha3code="tes", + capital="test", + region="test", + subregion="test", + population=1, + latitude=1, + longitude=2, + demonym="test", + area=1, + numeric_code=123, + flag="test", + currencies=[], + languages=[], + ) + self.news = News.objects.create( + country=country, + source="test", + author="test", + title="test", + description="test", + url="test", + published_at=datetime.now().astimezone(), + ) + News.objects.create( + country=country, + source="test2", + author="test2", + title="test2", + description="test2", + url="test2", + published_at=datetime.now().astimezone(), + ) + + def test_get_news(self) -> None: + """ + Тест получения новостей. + :return: + """ + response = self.client.get(reverse("news", kwargs={"alpha2code": "te"})) + data = response.json()["results"] + item = data[0] + self.assertEqual(len(data), 1) + self.assertEqual(item["source"], self.news.source) + self.assertEqual(item["author"], self.news.author) + self.assertEqual(item["title"], self.news.title) + self.assertEqual(item["description"], self.news.description) diff --git a/src/news/urls.py b/src/news/urls.py new file mode 100644 index 0000000..a784bf5 --- /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"), +] diff --git a/src/news/views.py b/src/news/views.py new file mode 100644 index 0000000..f91b03f --- /dev/null +++ b/src/news/views.py @@ -0,0 +1,42 @@ +"""Представления Django""" + +from django.core.cache import caches +from django.http import JsonResponse +from rest_framework.decorators import api_view +from rest_framework.exceptions import NotFound +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 + + +pagination_class = api_settings.DEFAULT_PAGINATION_CLASS +paginator = pagination_class() + + +@api_view(["GET"]) +def get_news(request: Request, alpha2code: str) -> JsonResponse: + """ + Получение новостной ленты для указанной страны. + + :param Request request: Объект запроса + :param str alpha2code: Название страны + :return: + """ + + cache_key = f"{alpha2code}_news" + 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: + page = paginator.paginate_queryset(data, request) + + serializer = NewsSerializer(page, many=True) + + return paginator.get_paginated_response(serializer.data) + + raise NotFound