diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23ed5c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,190 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environment +venv/ +ENV/ +env/ +.venv/ +env.bak/ +env.bak.bak/ + +# Pip environment +pip-log.txt +pip-delete-this-directory.txt + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +build/doctrees +build/html +build/latex +build/man +build/rst +build/xml + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# Editors and IDEs +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Generated files +*.log +*.csv +*.dat +*.tmp + +# JetBrains +.idea/ +*.iml +*.iws +*.ipr + +# Anaconda +.conda/ +.env + +# PyCharm +__pycache__/ +.idea/ +.vscode/ +*.iml +.DS_Store +*.pyc +*.pyo +.Python +env/ +venv/ +ENV/ +env.bak/ +env.bak.bak/ +.pytest_cache/ +.coverage +.coverage.* +.cache +.hypothesis/ +htmlcov/ +.tox/ +.pytest_cache/ + +# mypy +.mypy_cache/ + +# Coverage +htmlcov/ +.coverage + +# Logs +*.log + +# Local env files +.env +.env.* + +# MongoDB data +data/db/ + +# Sanic config and temp files + +sanic.log +*.pid +*.lock + + +# Miscellaneous +.vscode/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..312a5d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /code + +COPY requirements.txt /code/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /code/ + +COPY config.py /code/config.py + +EXPOSE 8000 + +CMD ["python", "manage.py"] diff --git a/README.md b/README.md index cfb476f..abf30fb 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,32 @@ -# Вакансия :: Программист Python +# Service API -Разработка бизнес-системы с использованием веб-технологий. Автоматизация сервисов с большим количеством пользователей +Этот проект предоставляет API для управления и мониторинга состояния различных сервисов. API позволяет: -## От вас +- Получать и сохранять данные о сервисах (имя, состояние, описание). +- Выводить список сервисов с их актуальным состоянием. +- Выдавать историю изменения состояния сервиса по его имени. +- Выдавать информацию о времени простоя сервиса и рассчитывать SLA (Service Level Agreement) в процентах за указанный + интервал времени. -### Обязательно +## Установка и запуск -- Знание синтаксиса языка Python -- Опыт разработки на Python не менее 1 года -- Базовые знания принципов работы Web -- Желание работать в команде и развиваться +1. Клонируйте репозиторий: -### Приветствуется + ```bash + git clone https://github.com/AlexYrlv/task-python.git + cd serviceс-api + ``` -- Навыки работы с Flask, Sanic, FastAPI -- Опыт работы с БД: PostgreSQL, MS SQL, MongoDB, ClickHouse -- Опыт разработки под ОС семейства GNU Linux, знание основных команд -- Работа с системами управления исходным кодом Git -- Знания базовых принципов разработки (тестирование, рефакторинг, Code Review, CI/CD) +2. Запустите контейнеры -### Будет круто, но не обязательно + ```bash + docker-compose up --build + ``` -- Знание английского языка на уровне чтения технической документации -- Участие в разработке Open Source проектов -- Наличие профиля на GitHub, Stack Overflow -- Наличие проектов которые можете показать нам -- Разработка с использованием TypeScript, знание современных frontend-библиотек и подходов к разработке +## Документация API -## У нас +3. После запуска можно проверить запросы по ссылке -- Полный рабочий день, гибкий обед и начало рабочего дня -- Полностью «белая» заработная плата с возможностью увеличения в процессе работы (зависит от отдачи сотрудника) -- Полис ДМС -- Дружелюбная команда с юмором, готовая поддержать и помочь -- Интересный проект и необычные задачи. Рутина тоже есть, но мы нацелены именно на продуктив -- Возможность одновременно участвовать в разных проектах и развивать другие компетенции (TS и все модное) -- Попробовать современные тренды и практики в разработке ПО -- Никаких опенспейсов и кубиклов, а комфортное пространство в центре Тюмени -- Готовы делиться опытом и знаниями, если вы готовы их получать - -  - -Если вакансия вас заинтересовала, но есть недопонимания и вопросы, свяжитесь с нами - обсудим, договоримся. -Большим плюсом будет выполнение тестового задания. -Если у вас есть опыт работы с 1С, то эта вакансия не для вас. - -## Тестовое задание - -Решение принимается в виде PR к текущему проекту. - -Есть несколько рабочих сервисов, у каждого сервиса есть состояние работает/не работает/работает нестабильно. - -Требуется написать API который: - -1. Получает и сохраняет данные: имя, состояние, описание -2. Выводит список сервисов с актуальным состоянием -3. По имени сервиса выдает историю изменения состояния и все данные по каждому состоянию - -Дополнительным плюсом будет - -1. По указанному интервалу выдается информация о том сколько не работал сервис и считать SLA в процентах до 3-й запятой - -Вывод всех данных должен быть в формате JSON + ```bash + http://localhost:8000/docs/swagger + ``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..9fe4f16 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,26 @@ +from sanic import Sanic +from sanic_ext import Extend +import os +from app.routes import ServiceRoutes +from app.db import init_db + + +app = Sanic("ServiceAPI") + +Extend(app, openapi_config={ + "title": "Service API", + "version": "1.0.0", + "description": "API для управления сервисами", +}) + +# Абсолютный путь к файлу config.py +config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../config.py') +app.config.update_config(config_path) + +# Инициализация базы данных +init_db(app.config.MONGODB_URL, app.config.DATABASE_NAME) + +# Регистрация маршрутов +ServiceRoutes.register_routes(app) + + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..cb67c83 --- /dev/null +++ b/app/db.py @@ -0,0 +1,4 @@ +from mongoengine import connect + +def init_db(uri: str, db_name: str): + connect(db_name, host=uri) diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..0e82f10 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,11 @@ +from sanic.exceptions import SanicException +from sanic import response + +class NotFound(SanicException): + status_code = 404 + +class ServerError(SanicException): + status_code = 500 + +def bad_request(request, exception): + return response.json({'error': str(exception)}, status=exception.status_code) diff --git a/app/loggers.py b/app/loggers.py new file mode 100644 index 0000000..1ddff8f --- /dev/null +++ b/app/loggers.py @@ -0,0 +1,9 @@ +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.StreamHandler() + ] +) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..cda6016 --- /dev/null +++ b/app/models.py @@ -0,0 +1,159 @@ +from __future__ import annotations +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any, TYPE_CHECKING, Tuple +from mongoengine import Document, StringField, DateTimeField, ObjectIdField, EmbeddedDocument, EmbeddedDocumentField, \ + ListField +from .exceptions import NotFound, ServerError +from bson import ObjectId + +VALID_STATES = ["работает", "не работает"] + + +class StateHistory(EmbeddedDocument): + state = StringField(required=True, choices=VALID_STATES) + timestamp = DateTimeField(default=datetime.utcnow) + timestamp_end = DateTimeField() + + def to_dict(self) -> dict: + return { + "state": self.state, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + "timestamp_end": self.timestamp_end.isoformat() if self.timestamp_end else None, + } + + +class Service(Document): + id = ObjectIdField(primary_key=True, default=lambda: ObjectId()) + name = StringField(required=True) + state = StringField(required=True, choices=VALID_STATES) + description = StringField() + timestamp = DateTimeField(default=datetime.utcnow) + timestamp_end = DateTimeField() + history = ListField(EmbeddedDocumentField(StateHistory), default=list) + + def to_dict(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "state": self.state, + "description": self.description, + "timestamp": self.timestamp.isoformat(), + "timestamp_end": self.timestamp_end.isoformat() if self.timestamp_end else None, + "history": [h.to_dict() for h in self.history] + } + + @classmethod + def get_all(cls) -> List[Service]: + return list(cls.objects) + + @classmethod + def create_or_update(cls, data: Dict[str, Any]) -> Service: + name = data.get("name") + new_state = data.get("state") + description = data.get("description") + + if not name or not new_state: + raise ValueError("Name and state are required") + + existing_service = cls.objects(name=name).first() + + if existing_service: + return cls.update_service_state(existing_service, new_state, description) + else: + return cls.create_new_service(data) + + @classmethod + def create_new_service(cls, data: Dict[str, Any]) -> Service: + service = cls(**data) + service.history.append(StateHistory(state=service.state, timestamp=service.timestamp)) + service.save() + return service + + @classmethod + def update_service_state(cls, existing_service: Service, new_state: str, description: Optional[str]) -> Service: + if existing_service.state == new_state: + raise ValueError(f"Service {existing_service.name} is already in state {new_state}") + + if existing_service.history: + existing_service.history[-1].timestamp_end = datetime.utcnow() + + existing_service.history.append(StateHistory(state=new_state, timestamp=datetime.utcnow())) + + existing_service.state = new_state + existing_service.description = description + existing_service.timestamp = datetime.utcnow() + existing_service.timestamp_end = None + existing_service.save() + return existing_service + + @classmethod + def get_history(cls, name: str) -> List[Service]: + services = list(cls.objects(name=name)) + if not services: + raise NotFound(f"No service found with name {name}") + return services + + @classmethod + def calculate_sla(cls, name: str, interval: str) -> Dict[str, Any]: + try: + interval_seconds = cls.parse_interval(interval) + end_time = datetime.utcnow() + start_time = end_time - timedelta(seconds=interval_seconds) + + total_time = interval_seconds + downtime = cls.calculate_downtime(name, start_time, end_time) + + uptime = total_time - downtime + sla = (uptime / total_time) * 100 + except NotFound as e: + raise NotFound(f"No service found with name {name}") + except Exception as e: + raise ServerError("Failed to calculate SLA") + + return {"sla": round(sla, 3), "downtime": round(downtime / 3600, 3)} + + @staticmethod + def parse_interval(interval: str) -> int: + if interval.endswith("h"): + return int(interval[:-1]) * 3600 + elif interval.endswith("d"): + return int(interval[:-1]) * 86400 + else: + raise ValueError('Invalid interval format. Use "h" for hours or "d" for days.') + + @classmethod + def calculate_downtime(cls, name: str, start_time: datetime, end_time: datetime) -> int: + service_exists = cls.objects(name=name).first() + if not service_exists: + raise NotFound(f"No service found with name {name}") + + service_entries = cls.objects( + name=name, + __raw__={ + "$or": [ + {"history.timestamp": {"$gte": start_time, "$lt": end_time}}, + {"history.timestamp_end": {"$gte": start_time, "$lt": end_time}} + ] + } + ) + + downtime = 0 + for service in service_entries: + for entry in service.history: + service_start_time, service_end_time = cls.get_service_times(entry, start_time, end_time) + if entry.state != "работает": + downtime += (service_end_time - service_start_time).total_seconds() + + return downtime + + @staticmethod + def get_service_times(entry: StateHistory, start_time: datetime, end_time: datetime) -> Tuple[datetime, datetime]: + service_end_time = entry.timestamp_end or end_time + service_start_time = entry.timestamp + + if service_start_time < start_time: + service_start_time = start_time + if service_end_time > end_time: + service_end_time = end_time + + return service_start_time, service_end_time diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..7260886 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,109 @@ +from mongoengine import ValidationError +from sanic import Blueprint, response +from sanic.request import Request +from .exceptions import NotFound, ServerError +from sanic_ext import openapi +from typing import Optional +from .models import Service +from .loggers import logging + +logger = logging.getLogger(__name__) + +bp = Blueprint("service_routes") + + +class ServiceRoutes: + + @bp.post("/service") + @openapi.summary("Add new service") + @openapi.description("Add new service with name, state, and description") + @openapi.body({"application/json": {"name": str, "state": str, "description": Optional[str]}}) + @openapi.response(201, {"application/json": {"name": str, "state": str, "description": Optional[str]}}) + async def add_service(request: Request): + try: + data = request.json + logger.info(f"Received data: {data}") + service = Service.create_or_update(data) + except ValidationError as e: + logger.error(f"Validation error: {e}") + return response.json({"error": "Validation error", "details": e.message}, status=400) + except ValueError as e: + logger.error(f"Validation error: {e}") + return response.json({"error": str(e)}, status=400) + except Exception as e: + logger.exception("Failed to add or update service") + raise ServerError("Failed to add or update service") + + return response.json(service.to_dict(), status=201) + + @bp.put("/service/") + @openapi.summary("Update service state") + @openapi.description("Update the state existing service") + @openapi.body({"application/json": {"state": str}}) + @openapi.response(200, {"application/json": {"name": str, "state": str, "description": Optional[str]}}) + async def update_service(request: Request, name: str): + try: + data = request.json + data["name"] = name + logger.info(f"Received data for update: {data}") + service = Service.create_or_update(data) + except ValueError as e: + logger.error(f"Validation error: {e}") + return response.json({"error": str(e)}, status=400) + except NotFound as e: + logger.error(f"Service not found: {name}") + return response.json({"error": str(e)}, status=404) + except Exception as e: + logger.exception("Failed to update service") + raise ServerError("Failed to update service") + + return response.json(service.to_dict(), status=200) + + @bp.get("/service/") + @openapi.summary("Get service history") + @openapi.description("History service by name") + @openapi.response(200, {"application/json": {"history": list}}) + async def get_service_history(request: Request, name: str): + try: + services = Service.get_history(name) + except NotFound as e: + logger.error(f"Service not found: {name}") + return response.json({"error": str(e)}, status=404) + except Exception as e: + logger.exception(f"Failed to fetch service history for {name}") + raise ServerError("Failed to fetch service history for {name}") + + return response.json({"history": [service.to_dict() for service in services]}) + + @bp.get("/services") + @openapi.summary("Get all services") + @openapi.description("List all services") + @openapi.response(200, {"application/json": {"services": list}}) + async def get_services(request: Request): + try: + services = Service.get_all() + except Exception: + logger.exception("Failed to fetch services") + raise ServerError("Failed to fetch services") + + return response.json({"services": [service.to_dict() for service in services]}) + + @bp.get("/sla/") + @openapi.summary("Get SLA for a service") + @openapi.description("Calculate the SLA") + @openapi.parameter("interval", str, location="query", required=True, + description="Time interval (e.g., '24h' or '7d')") + @openapi.response(200, {"application/json": {"sla": float, "downtime": float}}) + async def get_service_sla(request: Request, name: str): + interval = request.args.get("interval") + try: + result = Service.calculate_sla(name, interval) + except Exception as e: + logger.exception(f"Failed to calculate SLA for {name}") + raise ServerError(f"Failed to calculate SLA for {name}") + + return response.json(result) + + @staticmethod + def register_routes(app): + app.blueprint(bp) diff --git a/app/serializers.py b/app/serializers.py new file mode 100644 index 0000000..422d612 --- /dev/null +++ b/app/serializers.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional +from bson import ObjectId + + +class ServiceSerializer(BaseModel): + id: Optional[str] = None + name: str + state: str + description: Optional[str] = None + timestamp: datetime = Field(default_factory=datetime.utcnow) + timestamp_end: Optional[datetime] = None + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat(), + ObjectId: lambda v: str(v) + } + + def to_mongo_dict(self) -> dict: + data = self.dict(exclude_none=True) + if 'id' in data: + data['_id'] = ObjectId(data.pop('id')) + return data diff --git a/config.py b/config.py new file mode 100644 index 0000000..2614bad --- /dev/null +++ b/config.py @@ -0,0 +1,3 @@ +MONGODB_URL = 'mongodb://mongo:27017' +DATABASE_NAME = 'service_db' +COLLECTION_NAME = 'services' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4df1098 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + mongo: + image: mongo:latest + container_name: mongo + ports: + - "27017:27017" + volumes: + - mongo-data:/data/db + + service_api: + build: + context: . + dockerfile: Dockerfile + container_name: service_api + depends_on: + - mongo + ports: + - "8000:8000" + environment: + - MONGODB_URL=mongodb://mongo:27017/service_db + - DATABASE_NAME=service_db + +volumes: + mongo-data: diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..e560f56 --- /dev/null +++ b/manage.py @@ -0,0 +1,5 @@ +from app import app + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7231897 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +aiofiles==24.1.0 +annotated-types==0.7.0 +attrs==23.2.0 +certifi==2024.6.2 +charset-normalizer==3.3.2 +dnspython==2.6.1 +html5tagger==1.3.0 +httptools==0.6.1 +idna==3.7 +inflection==0.5.1 +jsonschema==4.22.0 +jsonschema-specifications==2023.12.1 +mongoengine==0.28.2 +multidict==6.0.5 +openapi==2.0.0 +pydantic==2.7.4 +pydantic_core==2.18.4 +pymongo==4.8.0 +PyYAML==6.0.1 +referencing==0.35.1 +requests==2.32.3 +rpds-py==0.18.1 +sanic==23.12.1 +sanic-admin==0.0.6 +sanic-ext==23.12.0 +sanic-openapi==21.12.0 +sanic-routing==23.12.0 +sanic-swagger==0.0.2 +tracerite==1.1.1 +typing_extensions==4.12.2 +ujson==5.10.0 +urllib3==2.2.2 +uvloop==0.19.0 +watchdog==4.0.1 +websockets==12.0