From bd8414d5d5b340525fde77b6510e290ad70bfe17 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Mon, 1 Jul 2024 16:10:45 +0500 Subject: [PATCH 01/18] Initial commit --- .gitignore | 197 +++++++++++++++++++++++++++++++++++++++++++++ app/__init__.py | 35 ++++++++ app/exceptions.py | 15 ++++ app/models.py | 16 ++++ app/routes.py | 116 ++++++++++++++++++++++++++ app/serializers.py | 31 +++++++ app/test_api.py | 34 ++++++++ docs/index.html | 18 +++++ docs/openapi.yaml | 102 +++++++++++++++++++++++ manage.py | 5 ++ requirements.txt | 31 +++++++ 11 files changed, 600 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/exceptions.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/serializers.py create mode 100644 app/test_api.py create mode 100644 docs/index.html create mode 100644 docs/openapi.yaml create mode 100644 manage.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..419ef2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,197 @@ +# 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/ + +# Custom ignores +config.py + +# Sanic config and temp files +config.yaml +sanic.log +*.pid +*.lock + +# Docker +*.env +Dockerfile +docker-compose.yml + +# Miscellaneous +.vscode/ +.venv/ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ecf2374 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,35 @@ +from sanic import Sanic +from motor.motor_asyncio import AsyncIOMotorClient +from .exceptions import NotFound, ServerError, bad_request +from loguru import logger +import os +from sanic_ext import Extend + +app = Sanic("ServiceAPI") + + +# Абсолютный путь к файлу config.py +config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../config.py') +app.config.update_config(config_path) + +client = AsyncIOMotorClient(app.config.MONGODB_URL) +database = client[app.config.DATABASE_NAME] +collection = database[app.config.COLLECTION_NAME] + +app.error_handler.add(NotFound, bad_request) +app.error_handler.add(ServerError, bad_request) + +# Добавление Sanic Extensions +Extend(app) + +app.config.API_VERSION = '1.0.0' +app.config.API_TITLE = 'Service API' +app.config.API_DESCRIPTION = 'API for managing services' +app.config.API_TERMS_OF_SERVICE = 'https://your-terms-of-service.url' +app.config.API_CONTACT_EMAIL = 'your-email@example.com' + +from . import routes + + +# инициализирует приложение Sanic, подключает базу данных MongoDB + diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..a8987f4 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,15 @@ +from sanic.response import json +from sanic.exceptions import SanicException + +class NotFound(SanicException): + status_code = 404 + +class ServerError(SanicException): + status_code = 500 + +def bad_request(request, exception): + return json({'error': str(exception)}, status=exception.status_code) + +# Этот файл определяет исключения, которые могут возникать в приложении, и их обработчики. + +# а кастомные исключения (NotFound и ServerError) помогают обрабатывать ошибки и возвращать корректные HTTP-ответы. \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..0cae86e --- /dev/null +++ b/app/models.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + +class Service(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 + + + +# Этот файл содержит описание моделей данных. +# В данном случае у нас есть модель Service, которая представляет сервис с определенными атрибутами. \ No newline at end of file diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..7880ec6 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,116 @@ +from sanic import response +from sanic.request import Request +from bson import ObjectId +from pydantic import ValidationError +from datetime import datetime, timedelta +from . import app, collection +from .models import Service +from .serializers import ServiceSerializer +from .exceptions import NotFound, ServerError +from loguru import logger +from sanic_ext import openapi + +@app.post("/service") +@openapi.summary("Add a new service") +@openapi.description("Add a new service with name, state, and description") +@openapi.body({"application/json": ServiceSerializer.schema()}) +@openapi.response(201, {"application/json": ServiceSerializer.schema()}) +async def add_service(request: Request): + try: + data = request.json + logger.info(f"Received data: {data}") + # Завершить предыдущую запись для этого сервиса, если она существует + update_result = await collection.update_many( + {"name": data["name"], "timestamp_end": None}, + {"$set": {"timestamp_end": datetime.utcnow()}} + ) + logger.info(f"Updated previous records: {update_result.modified_count}") + service = Service(**data) + result = await collection.insert_one(service.dict(exclude={"id"})) + logger.info(f"Inserted new service with id: {result.inserted_id}") + service.id = str(result.inserted_id) + service_serialized = ServiceSerializer(**service.dict()) + return response.json(service_serialized.dict(), status=201) + except ValidationError as e: + logger.error(f"Validation error: {e.errors()}") + return response.json(e.errors(), status=400) + except Exception as e: + logger.exception("Failed to add service") + raise ServerError("Failed to add service") + +@app.get("/services") +@openapi.summary("Get all services") +@openapi.description("Retrieve a list of all services") +@openapi.response(200, {"application/json": {"services": list}}) +async def get_services(request: Request): + services = [] + try: + async for service in collection.find(): + service["_id"] = str(service["_id"]) + service_obj = Service(**service) + service_serialized = ServiceSerializer(**service_obj.dict()) + services.append(service_serialized.dict()) + return response.json({"services": services}) + except Exception: + logger.exception("Failed to fetch services") + raise ServerError("Failed to fetch services") + +@app.get("/service/") +@openapi.summary("Get service history") +@openapi.description("Get the history of a specific service by name") +@openapi.response(200, {"application/json": {"history": list}}) +async def get_service_history(request: Request, name: str): + services = [] + try: + async for service in collection.find({"name": name}): + service["_id"] = str(service["_id"]) + service_obj = Service(**service) + service_serialized = ServiceSerializer(**service_obj.dict()) + services.append(service_serialized.dict()) + if not services: + raise NotFound(f"No service found with name {name}") + return response.json({"history": services}) + except NotFound as e: + raise e + except Exception: + logger.exception(f"Failed to fetch service history for {name}") + raise ServerError("Failed to fetch service history for {name}") + +@app.get("/sla/") +@openapi.summary("Get SLA for a service") +@openapi.description("Calculate the SLA for a service over a given interval") +@openapi.parameter("interval", str, location="query", required=True, description="Time interval (e.g., '24h' or '7d')") +@openapi.response(200, {"application/json": {"sla": float}}) +async def get_service_sla(request: Request, name: str): + interval = request.args.get("interval") # Получаем интервал из запроса + try: + # Вычисление интервала в секундах + if interval.endswith("h"): + interval_seconds = int(interval[:-1]) * 3600 + elif interval.endswith("d"): + interval_seconds = int(interval[:-1]) * 86400 + else: + return response.json({"error": 'Invalid interval format. Use "h" for hours or "d" for days.'}, status=400) + + end_time = datetime.utcnow() + start_time = end_time - timedelta(seconds=interval_seconds) + + total_time = interval_seconds + downtime = 0 + + async for service in collection.find({"name": name, "timestamp": {"$gte": start_time}}): + service_end_time = service.get("timestamp_end", end_time) + if service["state"] != "работает": + downtime += (service_end_time - service["timestamp"]).total_seconds() + + uptime = total_time - downtime + sla = (uptime / total_time) * 100 + + return response.json({"sla": round(sla, 3)}) + except Exception: + logger.exception(f"Failed to calculate SLA for {name}") + raise ServerError("Failed to calculate SLA for {name}") + + +# Этот файл содержит маршруты API, которые обрабатывают HTTP-запросы. +# Каждый маршрут представляет собой асинхронную функцию, которая принимает запрос, обрабатывает его и возвращает ответ. \ No newline at end of file diff --git a/app/serializers.py b/app/serializers.py new file mode 100644 index 0000000..2f0eb20 --- /dev/null +++ b/app/serializers.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional +from bson import ObjectId + +# ServiceSerializer используется для преобразования объектов Python в JSON и обратно, +# обеспечивая правильное форматирование дат и идентификаторов. +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: + from_attributes = True + json_encoders = { + datetime: lambda v: v.isoformat(), + ObjectId: lambda v: str(v) + } + + def dict(self, **kwargs): + data = super().dict(**kwargs) + if "timestamp" in data and data["timestamp"]: + data["timestamp"] = data["timestamp"].isoformat() + if "timestamp_end" in data and data["timestamp_end"]: + data["timestamp_end"] = data["timestamp_end"].isoformat() + return data +# Этот файл отвечает за сериализацию и десериализацию данных. +# Он помогает преобразовать объекты Python в JSON и наоборот. \ No newline at end of file diff --git a/app/test_api.py b/app/test_api.py new file mode 100644 index 0000000..99f7974 --- /dev/null +++ b/app/test_api.py @@ -0,0 +1,34 @@ +import requests + +BASE_URL = "http://localhost:8000" + +def test_add_service(): + url = f"{BASE_URL}/service" + data = { + "name": "Service1", + "state": "работает", + "description": "Описание сервиса 1" + } + response = requests.post(url, json=data) + print("Add Service:", response.status_code, response.json()) + +def test_get_services(): + url = f"{BASE_URL}/services" + response = requests.get(url) + print("Get Services:", response.status_code, response.json()) + +def test_get_service_history(): + url = f"{BASE_URL}/service/Service1" + response = requests.get(url) + print("Get Service History:", response.status_code, response.json()) + +def test_get_sla(): + url = f"{BASE_URL}/sla/Service1?interval=24h" + response = requests.get(url) + print("Get SLA:", response.status_code, response.json()) + +if __name__ == "__main__": + test_add_service() + test_get_services() + test_get_service_history() + test_get_sla() diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..ff898aa --- /dev/null +++ b/docs/index.html @@ -0,0 +1,18 @@ + + + + API Documentation + + + + + + + + + diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..515fbc1 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,102 @@ +openapi: 3.0.0 +info: + title: Service API + description: API for managing services + version: 1.0.0 +servers: + - url: http://localhost:8000 +paths: + /service: + post: + summary: Add a new service + description: Add a new service with name, state, and description + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + responses: + '201': + description: Service created + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + /services: + get: + summary: Get all services + description: Retrieve a list of all services + responses: + '200': + description: List of services + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Service' + /service/{name}: + get: + summary: Get service history + description: Get the history of a specific service by name + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + '200': + description: Service history + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Service' + /sla/{name}: + get: + summary: Get SLA for a service + description: Calculate the SLA for a service over a given interval + parameters: + - name: name + in: path + required: true + schema: + type: string + - name: interval + in: query + required: true + schema: + type: string + example: '24h' + responses: + '200': + description: SLA value + content: + application/json: + schema: + type: object + properties: + sla: + type: number +components: + schemas: + Service: + type: object + properties: + id: + type: string + name: + type: string + state: + type: string + description: + type: string + timestamp: + type: string + format: date-time + timestamp_end: + type: string + format: date-time 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..d79fad6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +aiofiles==24.1.0 +annotated-types==0.7.0 +attrs==23.2.0 +certifi==2024.6.2 +charset-normalizer==3.3.2 +debugpy==1.8.2 +dnspython==2.6.1 +html5tagger==1.3.0 +httptools==0.6.1 +idna==3.7 +loguru==0.7.2 +motor==3.5.0 +multidict==6.0.5 +pydantic==2.7.4 +pydantic_core==2.18.4 +pymongo==4.8.0 +PyYAML==6.0.1 +requests==2.32.3 +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 From 0b2a0b8d65b2f689e6554c8b82921211c2ddd964 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Mon, 1 Jul 2024 16:16:10 +0500 Subject: [PATCH 02/18] Add docker-build files --- .gitignore | 9 +-------- Dockerfile | 26 ++++++++++++++++++++++++++ config.py | 7 +++++++ docker-compose.yml | 29 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 Dockerfile create mode 100644 config.py create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 419ef2c..23ed5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -178,19 +178,12 @@ htmlcov/ # MongoDB data data/db/ -# Custom ignores -config.py - # Sanic config and temp files -config.yaml + sanic.log *.pid *.lock -# Docker -*.env -Dockerfile -docker-compose.yml # Miscellaneous .vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..598e112 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.9-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Set work directory +WORKDIR /code + +# Install dependencies +COPY requirements.txt /code/ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . /code/ + +# Copy config +COPY config.py /code/config.py + +# Expose the port the app runs on +EXPOSE 8000 + +# Run the application +CMD ["python", "manage.py"] diff --git a/config.py b/config.py new file mode 100644 index 0000000..44f9a35 --- /dev/null +++ b/config.py @@ -0,0 +1,7 @@ +import os + +MONGODB_URL = os.getenv('MONGODB_URL', 'mongodb://mongo:27017') +DATABASE_NAME = os.getenv('DATABASE_NAME', 'service_db') +COLLECTION_NAME = os.getenv('COLLECTION_NAME', 'services') + +# Файл конфигурации для хранения настроек приложения, таких как URL базы данных. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f354f69 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + + web: + build: . + container_name: service_api + command: python manage.py + volumes: + - .:/code + ports: + - "8000:8000" + depends_on: + - mongo + + mongo: + image: mongo:4.4 + container_name: mongo + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: + + +#mongo: Контейнер MongoDB для хранения данных. +#service_api: Контейнер вашего приложения Sanic. \ No newline at end of file From 660e4d7e9aafd5a2bb0c1992f069fc0daeeac770 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Wed, 3 Jul 2024 19:04:31 +0500 Subject: [PATCH 03/18] test --- Dockerfile | 11 +-- README.md | 152 ++++++++++++++++++++++++++++++++++++++++++ app/__init__.py | 55 ++++++++++----- app/exceptions.py | 4 -- app/models.py | 162 +++++++++++++++++++++++++++++++++++++++++---- app/routes.py | 145 +++++++++++++++++----------------------- app/serializers.py | 5 +- app/test_api.py | 34 ---------- config.py | 10 +-- docker-compose.yml | 2 - docs/index.html | 18 ----- docs/openapi.yaml | 102 ---------------------------- manage.py | 2 +- requirements.txt | 3 - 14 files changed, 409 insertions(+), 296 deletions(-) create mode 100644 README.md delete mode 100644 app/test_api.py delete mode 100644 docs/index.html delete mode 100644 docs/openapi.yaml diff --git a/Dockerfile b/Dockerfile index 598e112..312a5d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,17 @@ -# Use the official lightweight Python image. -# https://hub.docker.com/_/python -FROM python:3.9-slim +FROM python:3.10-slim -# Set environment variables ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 -# Set work directory WORKDIR /code -# Install dependencies COPY requirements.txt /code/ RUN pip install --no-cache-dir -r requirements.txt -# Copy project COPY . /code/ -# Copy config COPY config.py /code/config.py -# Expose the port the app runs on EXPOSE 8000 -# Run the application CMD ["python", "manage.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..656b68c --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Service API + +Этот проект предоставляет API для управления и мониторинга состояния различных сервисов. API позволяет: + +- Получать и сохранять данные о сервисах (имя, состояние, описание). +- Выводить список сервисов с их актуальным состоянием. +- Выдавать историю изменения состояния сервиса по его имени. +- Выдавать информацию о времени простоя сервиса и рассчитывать SLA (Service Level Agreement) в процентах за указанный + интервал времени. + +## Установка и запуск + +1. Клонируйте репозиторий: + + ```bash + git clone https://github.com/yourusername/service-api.git + cd service-api + +2. Запустите контейнеры + + ```bash + docker-compose up --build + ``` + +## Документация API + +3. После запуска можно проверить запросы по ссылке + + ```bash + http://localhost:8000/docs/swagger. + ``` + +## Примеры запросов + +- Добавить новый сервис + ```bash + POST http://localhost:8000/service + + BODY(JSON): + { + "name": "Service1", + "state": "работает", + "description": "Описание сервиса 1" + } + + + RESPONSE(201): + + { + "_id": "66854af23c98e7c594465936", + "name": "Service3", + "state": "работает", + "description": "Описание сервиса 1", + "timestamp": "2024-07-03T12:58:26.365389", + "timestamp_end": null + } + + ``` +- Обновить состояние сервиса + ```bash + PUT http://localhost:8000/service/Service1 + + REQUEST(JSON): + { + "state": "не работает" + } + + RESPONSE(200) + { + "_id": "66854af73c98e7c594465937", + "name": "Service1", + "state": "не работает", + "description": null, + "timestamp": "2024-07-03T12:58:31.205178", + "timestamp_end": null + } + +- Получить историю сервиса + ```bash + GET http://localhost:8000/service/Service1 + + RESPONSE: + { + "history": [ + { + "_id": "66854af23c98e7c594465936", + "name": "Service1", + "state": "работает", + "description": "Описание сервиса 1", + "timestamp": "2024-07-03T12:58:26.365389", + "timestamp_end": "2024-07-03T12:58:31.201000" + }, + { + "_id": "66854af73c98e7c594465937", + "name": "Service1", + "state": "не работает", + "description": null, + "timestamp": "2024-07-03T12:58:31.205178", + "timestamp_end": null + } + ] + } +- Получить все сервисы + ```bash + GET http://localhost:8000/services + + RESPONSE: + { + "services": [ + { + "_id": "668503f8225d7a5645a5dd1a", + "name": "Service1", + "state": "работает", + "description": "Описание сервиса 1", + "timestamp": "2024-07-03T07:55:36.801593", + "timestamp_end": "2024-07-03T07:57:00.052000" + }, + { + "_id": "6685044c225d7a5645a5dd1b", + "name": "Service1", + "state": "не работает", + "description": null, + "timestamp": "2024-07-03T07:57:00.056868", + "timestamp_end": "2024-07-03T07:58:47.221000" + }, + { + "_id": "668504b7225d7a5645a5dd1c", + "name": "Service1", + "state": "работает", + "description": null, + "timestamp": "2024-07-03T07:58:47.225278", + "timestamp_end": "2024-07-03T12:56:53.155000" + }, + ] + } +- Получить SLA для сервиса + + ```bash + ОБРАТИТЕ ВНИМАНИЕ!!! + ---- ---- ---- ---- ---- ---- ---- ---- ---- + SLA МОЖНО РАССЧИТАТЬ ТОЛЬКО КОГДА СЕРВИС В ДАННЫЙ + МОМЕНТ РАБОТАЕТ,ЕСЛИ В ПОСЛЕДНЕЙ ЗАПИСИ "state" "не работает", + ТО ОБНОВИТЕ ЕГО + ---- ---- ---- ---- ---- ---- ---- ---- ---- + + GET http://localhost:8000/sla/Service1?interval=24h + + RESPONSE: + { + "sla": 100.0, + "downtime": 0.0 + } \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index ecf2374..c4818a0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,35 +1,60 @@ +# # app/__init__.py +# from loguru import logger +# from sanic import Sanic +# from motor.motor_asyncio import AsyncIOMotorClient +# from .exceptions import NotFound, ServerError, bad_request +# import os +# from sanic_ext import Extend +# +# app = Sanic("ServiceAPI") +# +# config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../config.py') +# app.config.update_config(config_path) +# +# logger.info("Connecting to MongoDB at {}", app.config.MONGODB_URL) +# +# client = AsyncIOMotorClient(app.config.MONGODB_URL, serverSelectionTimeoutMS=50000, socketTimeoutMS=50000) +# database = client[app.config.DATABASE_NAME] +# collection = database[app.config.COLLECTION_NAME] +# +# logger.info("Successfully connected to MongoDB") +# +# app.error_handler.add(NotFound, bad_request) +# app.error_handler.add(ServerError, bad_request) +# +# Extend(app) +# +# app.config.API_VERSION = '1.0.0' +# app.config.API_TITLE = 'Service API' +# app.config.API_DESCRIPTION = 'API for managing services' +# +# +# from . import routes +# from sanic import Sanic from motor.motor_asyncio import AsyncIOMotorClient from .exceptions import NotFound, ServerError, bad_request -from loguru import logger import os from sanic_ext import Extend app = Sanic("ServiceAPI") +# Настройка Sanic Extensions и OpenAPI с использованием Swagger UI +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) -client = AsyncIOMotorClient(app.config.MONGODB_URL) +client = AsyncIOMotorClient(app.config.MONGODB_URL, serverSelectionTimeoutMS=50000, socketTimeoutMS=50000) database = client[app.config.DATABASE_NAME] collection = database[app.config.COLLECTION_NAME] app.error_handler.add(NotFound, bad_request) app.error_handler.add(ServerError, bad_request) -# Добавление Sanic Extensions -Extend(app) - -app.config.API_VERSION = '1.0.0' -app.config.API_TITLE = 'Service API' -app.config.API_DESCRIPTION = 'API for managing services' -app.config.API_TERMS_OF_SERVICE = 'https://your-terms-of-service.url' -app.config.API_CONTACT_EMAIL = 'your-email@example.com' - from . import routes - - -# инициализирует приложение Sanic, подключает базу данных MongoDB - diff --git a/app/exceptions.py b/app/exceptions.py index a8987f4..46a928f 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -9,7 +9,3 @@ class ServerError(SanicException): def bad_request(request, exception): return json({'error': str(exception)}, status=exception.status_code) - -# Этот файл определяет исключения, которые могут возникать в приложении, и их обработчики. - -# а кастомные исключения (NotFound и ServerError) помогают обрабатывать ошибки и возвращать корректные HTTP-ответы. \ No newline at end of file diff --git a/app/models.py b/app/models.py index 0cae86e..1179a2b 100644 --- a/app/models.py +++ b/app/models.py @@ -1,16 +1,154 @@ -from pydantic import BaseModel, Field -from datetime import datetime -from typing import Optional +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from motor.motor_asyncio import AsyncIOMotorCollection +from bson import ObjectId -class Service(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 +from app import NotFound, ServerError +class Service: + def __init__(self, name: str, state: str, id: Optional[str] = None, description: Optional[str] = None, + timestamp: Optional[datetime] = None, + timestamp_end: Optional[datetime] = None): + self.id = id or str(ObjectId()) + self.name = name + self.state = self.validate_state(state) + self.description = description + self.timestamp = timestamp or datetime.utcnow() + self.timestamp_end = timestamp_end -# Этот файл содержит описание моделей данных. -# В данном случае у нас есть модель Service, которая представляет сервис с определенными атрибутами. \ No newline at end of file + @staticmethod + def validate_state(state: str) -> str: + valid_states = ["работает", "не работает"] + if state not in valid_states: + raise ValueError(f"Invalid state: {state}. State must be one of {valid_states}") + return state + + def to_dict(self) -> dict: + return { + "_id": 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, + } + + @classmethod + def from_dict(cls, data: dict) -> "Service": + return cls( + id=str(data.get("_id")), + name=data["name"], + state=data["state"], + description=data.get("description"), + timestamp=datetime.fromisoformat(data["timestamp"]) if isinstance(data["timestamp"], str) else data[ + "timestamp"], + timestamp_end=datetime.fromisoformat(data["timestamp_end"]) if data.get("timestamp_end") and isinstance( + data["timestamp_end"], str) else data["timestamp_end"] + ) + + @classmethod + async def get_all(cls, collection: AsyncIOMotorCollection) -> List["Service"]: + services = [] + async for document in collection.find(): + services.append(cls.from_dict(document)) + return services + + @classmethod + async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> "Service": + name = data.get("name") + new_state = data.get("state") + if not name: + raise ValueError("Name is required") + if not new_state: + raise ValueError("State is required") + + await cls.update_end_timestamp(collection, name) + + existing_service = await collection.find_one({"name": name, "timestamp_end": None}) + if existing_service: + if existing_service["state"] == new_state: + raise ValueError(f"Service {name} is already in state {new_state}") + + await collection.update_one( + {"_id": existing_service["_id"]}, + {"$set": {"timestamp_end": datetime.utcnow()}} + ) + + service = cls(name=name, state=new_state, description=data.get("description")) + result = await collection.insert_one(service.to_dict()) + service.id = str(result.inserted_id) + return service + else: + service = cls(**data) + result = await collection.insert_one(service.to_dict()) + service.id = str(result.inserted_id) + return service + + @classmethod + async def update_end_timestamp(cls, collection: AsyncIOMotorCollection, name: str) -> int: + result = await collection.update_many( + {"name": name, "timestamp_end": None}, + {"$set": {"timestamp_end": datetime.utcnow()}} + ) + return result.modified_count + + @classmethod + async def get_history(cls, collection: AsyncIOMotorCollection, name: str) -> List["Service"]: + services = [] + async for document in collection.find({"name": name}): + services.append(cls.from_dict(document)) + if not services: + raise NotFound(f"No service found with name {name}") + return services + + @classmethod + async def calculate_sla(cls, collection: AsyncIOMotorCollection, name: str, interval: str) -> Dict[str, Any]: + try: + if interval.endswith("h"): + interval_seconds = int(interval[:-1]) * 3600 + elif interval.endswith("d"): + interval_seconds = int(interval[:-1]) * 86400 + else: + return {"error": 'Invalid interval format. Use "h" for hours or "d" for days.'} + + end_time = datetime.utcnow() + start_time = end_time - timedelta(seconds=interval_seconds) + + total_time = interval_seconds + downtime = 0 + + service_exists = await collection.find_one({"name": name}) + if not service_exists: + raise NotFound(f"No service found with name {name}") + + service_entries = await collection.find({"name": name, "$or": [ + {"timestamp": {"$gte": start_time, "$lt": end_time}}, + {"timestamp_end": {"$gte": start_time, "$lt": end_time}} + ]}).sort("timestamp").to_list(length=None) + + for service in service_entries: + service_end_time = service.get("timestamp_end", end_time) + if isinstance(service_end_time, str): + service_end_time = datetime.fromisoformat(service_end_time) + if isinstance(service["timestamp"], str): + service_start_time = datetime.fromisoformat(service["timestamp"]) + else: + service_start_time = service["timestamp"] + + if service_start_time < start_time: + service_start_time = start_time + if service_end_time > end_time: + service_end_time = end_time + + if service["state"] != "работает": + downtime += (service_end_time - service_start_time).total_seconds() + + uptime = total_time - downtime + sla = (uptime / total_time) * 100 + + return {"sla": round(sla, 3), "downtime": round(downtime / 3600, 3)} + except NotFound as e: + raise e + except Exception as e: + raise ServerError("Failed to calculate SLA") diff --git a/app/routes.py b/app/routes.py index 7880ec6..01d46e0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,116 +1,93 @@ from sanic import response from sanic.request import Request -from bson import ObjectId -from pydantic import ValidationError -from datetime import datetime, timedelta from . import app, collection from .models import Service -from .serializers import ServiceSerializer from .exceptions import NotFound, ServerError from loguru import logger from sanic_ext import openapi +from typing import Optional @app.post("/service") -@openapi.summary("Add a new service") -@openapi.description("Add a new service with name, state, and description") -@openapi.body({"application/json": ServiceSerializer.schema()}) -@openapi.response(201, {"application/json": ServiceSerializer.schema()}) +@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}") - # Завершить предыдущую запись для этого сервиса, если она существует - update_result = await collection.update_many( - {"name": data["name"], "timestamp_end": None}, - {"$set": {"timestamp_end": datetime.utcnow()}} - ) - logger.info(f"Updated previous records: {update_result.modified_count}") - service = Service(**data) - result = await collection.insert_one(service.dict(exclude={"id"})) - logger.info(f"Inserted new service with id: {result.inserted_id}") - service.id = str(result.inserted_id) - service_serialized = ServiceSerializer(**service.dict()) - return response.json(service_serialized.dict(), status=201) - except ValidationError as e: - logger.error(f"Validation error: {e.errors()}") - return response.json(e.errors(), status=400) + + service = await Service.create_or_update(collection, data) + return response.json(service.to_dict(), status=201) + + 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 service") - raise ServerError("Failed to add service") + logger.exception("Failed to add or update service") + raise ServerError("Failed to add or update service") -@app.get("/services") -@openapi.summary("Get all services") -@openapi.description("Retrieve a list of all services") -@openapi.response(200, {"application/json": {"services": list}}) -async def get_services(request: Request): - services = [] +@app.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: - async for service in collection.find(): - service["_id"] = str(service["_id"]) - service_obj = Service(**service) - service_serialized = ServiceSerializer(**service_obj.dict()) - services.append(service_serialized.dict()) - return response.json({"services": services}) - except Exception: - logger.exception("Failed to fetch services") - raise ServerError("Failed to fetch services") + data = request.json + data["name"] = name + logger.info(f"Received data for update: {data}") + + service = await Service.create_or_update(collection, data) + return response.json(service.to_dict(), status=200) + + 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") @app.get("/service/") @openapi.summary("Get service history") -@openapi.description("Get the history of a specific service by name") +@openapi.description("History service by name") @openapi.response(200, {"application/json": {"history": list}}) async def get_service_history(request: Request, name: str): - services = [] try: - async for service in collection.find({"name": name}): - service["_id"] = str(service["_id"]) - service_obj = Service(**service) - service_serialized = ServiceSerializer(**service_obj.dict()) - services.append(service_serialized.dict()) - if not services: - raise NotFound(f"No service found with name {name}") - return response.json({"history": services}) + services = await Service.get_history(collection, name) + return response.json({"history": [service.to_dict() for service in services]}) except NotFound as e: - raise e - except Exception: + 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}") +@app.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 = await Service.get_all(collection) + return response.json({"services": [service.to_dict() for service in services]}) + except Exception: + logger.exception("Failed to fetch services") + raise ServerError("Failed to fetch services") + @app.get("/sla/") @openapi.summary("Get SLA for a service") -@openapi.description("Calculate the SLA for a service over a given interval") +@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}}) +@openapi.response(200, {"application/json": {"sla": float, "downtime": float}}) async def get_service_sla(request: Request, name: str): - interval = request.args.get("interval") # Получаем интервал из запроса + interval = request.args.get("interval") try: - # Вычисление интервала в секундах - if interval.endswith("h"): - interval_seconds = int(interval[:-1]) * 3600 - elif interval.endswith("d"): - interval_seconds = int(interval[:-1]) * 86400 - else: - return response.json({"error": 'Invalid interval format. Use "h" for hours or "d" for days.'}, status=400) - - end_time = datetime.utcnow() - start_time = end_time - timedelta(seconds=interval_seconds) - - total_time = interval_seconds - downtime = 0 - - async for service in collection.find({"name": name, "timestamp": {"$gte": start_time}}): - service_end_time = service.get("timestamp_end", end_time) - if service["state"] != "работает": - downtime += (service_end_time - service["timestamp"]).total_seconds() - - uptime = total_time - downtime - sla = (uptime / total_time) * 100 - - return response.json({"sla": round(sla, 3)}) - except Exception: + result = await Service.calculate_sla(collection, name, interval) + return response.json(result) + except Exception as e: logger.exception(f"Failed to calculate SLA for {name}") - raise ServerError("Failed to calculate SLA for {name}") - - -# Этот файл содержит маршруты API, которые обрабатывают HTTP-запросы. -# Каждый маршрут представляет собой асинхронную функцию, которая принимает запрос, обрабатывает его и возвращает ответ. \ No newline at end of file + raise ServerError(f"Failed to calculate SLA for {name}") diff --git a/app/serializers.py b/app/serializers.py index 2f0eb20..7336b2e 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -3,8 +3,7 @@ from typing import Optional from bson import ObjectId -# ServiceSerializer используется для преобразования объектов Python в JSON и обратно, -# обеспечивая правильное форматирование дат и идентификаторов. + class ServiceSerializer(BaseModel): id: Optional[str] = None name: str @@ -27,5 +26,3 @@ def dict(self, **kwargs): if "timestamp_end" in data and data["timestamp_end"]: data["timestamp_end"] = data["timestamp_end"].isoformat() return data -# Этот файл отвечает за сериализацию и десериализацию данных. -# Он помогает преобразовать объекты Python в JSON и наоборот. \ No newline at end of file diff --git a/app/test_api.py b/app/test_api.py deleted file mode 100644 index 99f7974..0000000 --- a/app/test_api.py +++ /dev/null @@ -1,34 +0,0 @@ -import requests - -BASE_URL = "http://localhost:8000" - -def test_add_service(): - url = f"{BASE_URL}/service" - data = { - "name": "Service1", - "state": "работает", - "description": "Описание сервиса 1" - } - response = requests.post(url, json=data) - print("Add Service:", response.status_code, response.json()) - -def test_get_services(): - url = f"{BASE_URL}/services" - response = requests.get(url) - print("Get Services:", response.status_code, response.json()) - -def test_get_service_history(): - url = f"{BASE_URL}/service/Service1" - response = requests.get(url) - print("Get Service History:", response.status_code, response.json()) - -def test_get_sla(): - url = f"{BASE_URL}/sla/Service1?interval=24h" - response = requests.get(url) - print("Get SLA:", response.status_code, response.json()) - -if __name__ == "__main__": - test_add_service() - test_get_services() - test_get_service_history() - test_get_sla() diff --git a/config.py b/config.py index 44f9a35..2614bad 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,3 @@ -import os - -MONGODB_URL = os.getenv('MONGODB_URL', 'mongodb://mongo:27017') -DATABASE_NAME = os.getenv('DATABASE_NAME', 'service_db') -COLLECTION_NAME = os.getenv('COLLECTION_NAME', 'services') - -# Файл конфигурации для хранения настроек приложения, таких как URL базы данных. \ No newline at end of file +MONGODB_URL = 'mongodb://mongo:27017' +DATABASE_NAME = 'service_db' +COLLECTION_NAME = 'services' diff --git a/docker-compose.yml b/docker-compose.yml index f354f69..101f4a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,5 +25,3 @@ volumes: mongo_data: -#mongo: Контейнер MongoDB для хранения данных. -#service_api: Контейнер вашего приложения Sanic. \ No newline at end of file diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index ff898aa..0000000 --- a/docs/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - API Documentation - - - - - - - - - diff --git a/docs/openapi.yaml b/docs/openapi.yaml deleted file mode 100644 index 515fbc1..0000000 --- a/docs/openapi.yaml +++ /dev/null @@ -1,102 +0,0 @@ -openapi: 3.0.0 -info: - title: Service API - description: API for managing services - version: 1.0.0 -servers: - - url: http://localhost:8000 -paths: - /service: - post: - summary: Add a new service - description: Add a new service with name, state, and description - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Service' - responses: - '201': - description: Service created - content: - application/json: - schema: - $ref: '#/components/schemas/Service' - /services: - get: - summary: Get all services - description: Retrieve a list of all services - responses: - '200': - description: List of services - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Service' - /service/{name}: - get: - summary: Get service history - description: Get the history of a specific service by name - parameters: - - name: name - in: path - required: true - schema: - type: string - responses: - '200': - description: Service history - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Service' - /sla/{name}: - get: - summary: Get SLA for a service - description: Calculate the SLA for a service over a given interval - parameters: - - name: name - in: path - required: true - schema: - type: string - - name: interval - in: query - required: true - schema: - type: string - example: '24h' - responses: - '200': - description: SLA value - content: - application/json: - schema: - type: object - properties: - sla: - type: number -components: - schemas: - Service: - type: object - properties: - id: - type: string - name: - type: string - state: - type: string - description: - type: string - timestamp: - type: string - format: date-time - timestamp_end: - type: string - format: date-time diff --git a/manage.py b/manage.py index e560f56..89d085b 100644 --- a/manage.py +++ b/manage.py @@ -2,4 +2,4 @@ if __name__ == '__main__': - app.run(host='0.0.0.0', port=8000) + app.run(host='0.0.0.0', port=8000, debug=True) diff --git a/requirements.txt b/requirements.txt index d79fad6..e6b1982 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ annotated-types==0.7.0 attrs==23.2.0 certifi==2024.6.2 charset-normalizer==3.3.2 -debugpy==1.8.2 dnspython==2.6.1 html5tagger==1.3.0 httptools==0.6.1 @@ -11,8 +10,6 @@ idna==3.7 loguru==0.7.2 motor==3.5.0 multidict==6.0.5 -pydantic==2.7.4 -pydantic_core==2.18.4 pymongo==4.8.0 PyYAML==6.0.1 requests==2.32.3 From fdc20fda06b59304d4a2084b16911e10185ab079 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Wed, 3 Jul 2024 19:06:10 +0500 Subject: [PATCH 04/18] change README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 656b68c..bc1ecc8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 1. Клонируйте репозиторий: ```bash - git clone https://github.com/yourusername/service-api.git + git clone https://github.com/AlexYrlv/service-api.git cd service-api 2. Запустите контейнеры From a703ea57e6b93e45998eb037b8483e3f7ba29d75 Mon Sep 17 00:00:00 2001 From: Aleksandr Yurlov <108286754+AlexYrlv@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:10:41 +0500 Subject: [PATCH 05/18] Update README.md --- README.md | 121 ------------------------------------------------------ 1 file changed, 121 deletions(-) diff --git a/README.md b/README.md index bc1ecc8..c50e188 100644 --- a/README.md +++ b/README.md @@ -29,124 +29,3 @@ ```bash http://localhost:8000/docs/swagger. ``` - -## Примеры запросов - -- Добавить новый сервис - ```bash - POST http://localhost:8000/service - - BODY(JSON): - { - "name": "Service1", - "state": "работает", - "description": "Описание сервиса 1" - } - - - RESPONSE(201): - - { - "_id": "66854af23c98e7c594465936", - "name": "Service3", - "state": "работает", - "description": "Описание сервиса 1", - "timestamp": "2024-07-03T12:58:26.365389", - "timestamp_end": null - } - - ``` -- Обновить состояние сервиса - ```bash - PUT http://localhost:8000/service/Service1 - - REQUEST(JSON): - { - "state": "не работает" - } - - RESPONSE(200) - { - "_id": "66854af73c98e7c594465937", - "name": "Service1", - "state": "не работает", - "description": null, - "timestamp": "2024-07-03T12:58:31.205178", - "timestamp_end": null - } - -- Получить историю сервиса - ```bash - GET http://localhost:8000/service/Service1 - - RESPONSE: - { - "history": [ - { - "_id": "66854af23c98e7c594465936", - "name": "Service1", - "state": "работает", - "description": "Описание сервиса 1", - "timestamp": "2024-07-03T12:58:26.365389", - "timestamp_end": "2024-07-03T12:58:31.201000" - }, - { - "_id": "66854af73c98e7c594465937", - "name": "Service1", - "state": "не работает", - "description": null, - "timestamp": "2024-07-03T12:58:31.205178", - "timestamp_end": null - } - ] - } -- Получить все сервисы - ```bash - GET http://localhost:8000/services - - RESPONSE: - { - "services": [ - { - "_id": "668503f8225d7a5645a5dd1a", - "name": "Service1", - "state": "работает", - "description": "Описание сервиса 1", - "timestamp": "2024-07-03T07:55:36.801593", - "timestamp_end": "2024-07-03T07:57:00.052000" - }, - { - "_id": "6685044c225d7a5645a5dd1b", - "name": "Service1", - "state": "не работает", - "description": null, - "timestamp": "2024-07-03T07:57:00.056868", - "timestamp_end": "2024-07-03T07:58:47.221000" - }, - { - "_id": "668504b7225d7a5645a5dd1c", - "name": "Service1", - "state": "работает", - "description": null, - "timestamp": "2024-07-03T07:58:47.225278", - "timestamp_end": "2024-07-03T12:56:53.155000" - }, - ] - } -- Получить SLA для сервиса - - ```bash - ОБРАТИТЕ ВНИМАНИЕ!!! - ---- ---- ---- ---- ---- ---- ---- ---- ---- - SLA МОЖНО РАССЧИТАТЬ ТОЛЬКО КОГДА СЕРВИС В ДАННЫЙ - МОМЕНТ РАБОТАЕТ,ЕСЛИ В ПОСЛЕДНЕЙ ЗАПИСИ "state" "не работает", - ТО ОБНОВИТЕ ЕГО - ---- ---- ---- ---- ---- ---- ---- ---- ---- - - GET http://localhost:8000/sla/Service1?interval=24h - - RESPONSE: - { - "sla": 100.0, - "downtime": 0.0 - } \ No newline at end of file From b75d3debaa00e11a37af4d75dad46d3f510d9805 Mon Sep 17 00:00:00 2001 From: Aleksandr Yurlov <108286754+AlexYrlv@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:10:59 +0500 Subject: [PATCH 06/18] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c50e188..a468ffb 100644 --- a/README.md +++ b/README.md @@ -27,5 +27,5 @@ 3. После запуска можно проверить запросы по ссылке ```bash - http://localhost:8000/docs/swagger. + http://localhost:8000/docs/swagger ``` From 4b7174946a39b09d265b91c93c7fd58c717d9331 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 14:37:22 +0500 Subject: [PATCH 07/18] change README --- app/__init__.py | 51 +----------- app/db.py | 7 ++ app/exceptions.py | 6 +- app/models.py | 189 ++++++++++++++++++++++++++++++--------------- app/routes.py | 168 +++++++++++++++++++++------------------- app/serializers.py | 15 ++-- requirements.txt | 10 +++ 7 files changed, 247 insertions(+), 199 deletions(-) create mode 100644 app/db.py diff --git a/app/__init__.py b/app/__init__.py index c4818a0..0968bcd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,41 +1,7 @@ -# # app/__init__.py -# from loguru import logger -# from sanic import Sanic -# from motor.motor_asyncio import AsyncIOMotorClient -# from .exceptions import NotFound, ServerError, bad_request -# import os -# from sanic_ext import Extend -# -# app = Sanic("ServiceAPI") -# -# config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../config.py') -# app.config.update_config(config_path) -# -# logger.info("Connecting to MongoDB at {}", app.config.MONGODB_URL) -# -# client = AsyncIOMotorClient(app.config.MONGODB_URL, serverSelectionTimeoutMS=50000, socketTimeoutMS=50000) -# database = client[app.config.DATABASE_NAME] -# collection = database[app.config.COLLECTION_NAME] -# -# logger.info("Successfully connected to MongoDB") -# -# app.error_handler.add(NotFound, bad_request) -# app.error_handler.add(ServerError, bad_request) -# -# Extend(app) -# -# app.config.API_VERSION = '1.0.0' -# app.config.API_TITLE = 'Service API' -# app.config.API_DESCRIPTION = 'API for managing services' -# -# -# from . import routes -# from sanic import Sanic -from motor.motor_asyncio import AsyncIOMotorClient -from .exceptions import NotFound, ServerError, bad_request -import os from sanic_ext import Extend +from .db import init_db +from .routes import ServiceRoutes app = Sanic("ServiceAPI") @@ -46,15 +12,6 @@ "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() -client = AsyncIOMotorClient(app.config.MONGODB_URL, serverSelectionTimeoutMS=50000, socketTimeoutMS=50000) -database = client[app.config.DATABASE_NAME] -collection = database[app.config.COLLECTION_NAME] - -app.error_handler.add(NotFound, bad_request) -app.error_handler.add(ServerError, bad_request) - -from . import routes +ServiceRoutes.register_routes(app) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..e3d9721 --- /dev/null +++ b/app/db.py @@ -0,0 +1,7 @@ +from mongoengine import connect +import os + +def init_db(): + db_name = os.getenv("DATABASE_NAME", "service_db") + db_host = os.getenv("MONGODB_URL", "mongodb://localhost:27017/service_db") + connect(db=db_name, host=db_host) diff --git a/app/exceptions.py b/app/exceptions.py index 46a928f..57fd962 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -1,5 +1,7 @@ -from sanic.response import json +# app/exceptions.py + from sanic.exceptions import SanicException +from sanic import response class NotFound(SanicException): status_code = 404 @@ -8,4 +10,4 @@ class ServerError(SanicException): status_code = 500 def bad_request(request, exception): - return json({'error': str(exception)}, status=exception.status_code) + return response.json({'error': str(exception)}, status=exception.status_code) diff --git a/app/models.py b/app/models.py index 1179a2b..5d7a6b3 100644 --- a/app/models.py +++ b/app/models.py @@ -1,15 +1,21 @@ +# app/models.py +from __future__ import annotations from datetime import datetime, timedelta from typing import Optional, List, Dict, Any from motor.motor_asyncio import AsyncIOMotorCollection from bson import ObjectId -from app import NotFound, ServerError +from .exceptions import NotFound, ServerError # Изменен импорт +VALID_STATES = ["работает", "не работает"] class Service: + """ + Класс, представляющий сервис с его состоянием и временными метками. + """ + def __init__(self, name: str, state: str, id: Optional[str] = None, description: Optional[str] = None, - timestamp: Optional[datetime] = None, - timestamp_end: Optional[datetime] = None): + timestamp: Optional[datetime] = None, timestamp_end: Optional[datetime] = None): self.id = id or str(ObjectId()) self.name = name self.state = self.validate_state(state) @@ -19,12 +25,17 @@ def __init__(self, name: str, state: str, id: Optional[str] = None, description: @staticmethod def validate_state(state: str) -> str: - valid_states = ["работает", "не работает"] - if state not in valid_states: - raise ValueError(f"Invalid state: {state}. State must be one of {valid_states}") + """ + Проверка допустимости состояния сервиса. + """ + if state not in VALID_STATES: + raise ValueError(f"Invalid state: {state}. State must be one of {VALID_STATES}") return state def to_dict(self) -> dict: + """ + Преобразование объекта сервиса в словарь. + """ return { "_id": self.id, "name": self.name, @@ -36,19 +47,24 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "Service": + """ + Создание объекта сервиса из словаря. + """ return cls( id=str(data.get("_id")), name=data["name"], state=data["state"], description=data.get("description"), - timestamp=datetime.fromisoformat(data["timestamp"]) if isinstance(data["timestamp"], str) else data[ - "timestamp"], + timestamp=datetime.fromisoformat(data["timestamp"]) if isinstance(data["timestamp"], str) else data["timestamp"], timestamp_end=datetime.fromisoformat(data["timestamp_end"]) if data.get("timestamp_end") and isinstance( data["timestamp_end"], str) else data["timestamp_end"] ) @classmethod async def get_all(cls, collection: AsyncIOMotorCollection) -> List["Service"]: + """ + Получение всех сервисов из коллекции. + """ services = [] async for document in collection.find(): services.append(cls.from_dict(document)) @@ -56,37 +72,57 @@ async def get_all(cls, collection: AsyncIOMotorCollection) -> List["Service"]: @classmethod async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> "Service": + """ + Создание нового или обновление существующего сервиса. + """ name = data.get("name") new_state = data.get("state") - if not name: - raise ValueError("Name is required") - if not new_state: - raise ValueError("State is required") + description = data.get("description") - await cls.update_end_timestamp(collection, name) + if not name or not new_state: + raise ValueError("Name and state are required") + await cls.update_end_timestamp(collection, name) existing_service = await collection.find_one({"name": name, "timestamp_end": None}) + if existing_service: - if existing_service["state"] == new_state: - raise ValueError(f"Service {name} is already in state {new_state}") - - await collection.update_one( - {"_id": existing_service["_id"]}, - {"$set": {"timestamp_end": datetime.utcnow()}} - ) - - service = cls(name=name, state=new_state, description=data.get("description")) - result = await collection.insert_one(service.to_dict()) - service.id = str(result.inserted_id) - return service + return await cls.update_service_state(collection, existing_service, new_state, description) else: - service = cls(**data) - result = await collection.insert_one(service.to_dict()) - service.id = str(result.inserted_id) - return service + return await cls.create_new_service(collection, data) + + @classmethod + async def create_new_service(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> "Service": + """ + Создание нового сервиса. + """ + service = cls(**data) + result = await collection.insert_one(service.to_dict()) + service.id = str(result.inserted_id) + return service + + @classmethod + async def update_service_state(cls, collection: AsyncIOMotorCollection, existing_service: dict, 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}") + + await collection.update_one( + {"_id": existing_service["_id"]}, + {"$set": {"timestamp_end": datetime.utcnow()}} + ) + + service = cls(name=existing_service["name"], state=new_state, description=description) + result = await collection.insert_one(service.to_dict()) + service.id = str(result.inserted_id) + return service @classmethod async def update_end_timestamp(cls, collection: AsyncIOMotorCollection, name: str) -> int: + """ + Обновление временной метки завершения для всех записей сервиса с незавершенным состоянием. + """ result = await collection.update_many( {"name": name, "timestamp_end": None}, {"$set": {"timestamp_end": datetime.utcnow()}} @@ -95,6 +131,9 @@ async def update_end_timestamp(cls, collection: AsyncIOMotorCollection, name: st @classmethod async def get_history(cls, collection: AsyncIOMotorCollection, name: str) -> List["Service"]: + """ + Получение истории изменения состояния сервиса. + """ services = [] async for document in collection.find({"name": name}): services.append(cls.from_dict(document)) @@ -104,45 +143,16 @@ async def get_history(cls, collection: AsyncIOMotorCollection, name: str) -> Lis @classmethod async def calculate_sla(cls, collection: AsyncIOMotorCollection, name: str, interval: str) -> Dict[str, Any]: + """ + Расчет SLA (Service Level Agreement) для сервиса за указанный интервал времени. + """ try: - if interval.endswith("h"): - interval_seconds = int(interval[:-1]) * 3600 - elif interval.endswith("d"): - interval_seconds = int(interval[:-1]) * 86400 - else: - return {"error": 'Invalid interval format. Use "h" for hours or "d" for days.'} - + interval_seconds = cls.parse_interval(interval) end_time = datetime.utcnow() start_time = end_time - timedelta(seconds=interval_seconds) total_time = interval_seconds - downtime = 0 - - service_exists = await collection.find_one({"name": name}) - if not service_exists: - raise NotFound(f"No service found with name {name}") - - service_entries = await collection.find({"name": name, "$or": [ - {"timestamp": {"$gte": start_time, "$lt": end_time}}, - {"timestamp_end": {"$gte": start_time, "$lt": end_time}} - ]}).sort("timestamp").to_list(length=None) - - for service in service_entries: - service_end_time = service.get("timestamp_end", end_time) - if isinstance(service_end_time, str): - service_end_time = datetime.fromisoformat(service_end_time) - if isinstance(service["timestamp"], str): - service_start_time = datetime.fromisoformat(service["timestamp"]) - else: - service_start_time = service["timestamp"] - - if service_start_time < start_time: - service_start_time = start_time - if service_end_time > end_time: - service_end_time = end_time - - if service["state"] != "работает": - downtime += (service_end_time - service_start_time).total_seconds() + downtime = await cls.calculate_downtime(collection, name, start_time, end_time) uptime = total_time - downtime sla = (uptime / total_time) * 100 @@ -152,3 +162,56 @@ async def calculate_sla(cls, collection: AsyncIOMotorCollection, name: str, inte raise e except Exception as e: raise ServerError("Failed to calculate SLA") + + @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 + async def calculate_downtime(cls, collection: AsyncIOMotorCollection, name: str, start_time: datetime, end_time: datetime) -> int: + """ + Расчет времени простоя сервиса. + """ + service_exists = await collection.find_one({"name": name}) + if not service_exists: + raise NotFound(f"No service found with name {name}") + + service_entries = await collection.find({"name": name, "$or": [ + {"timestamp": {"$gte": start_time, "$lt": end_time}}, + {"timestamp_end": {"$gte": start_time, "$lt": end_time}} + ]}).sort("timestamp").to_list(length=None) + + downtime = 0 + for service in service_entries: + service_start_time, service_end_time = cls.get_service_times(service, start_time, end_time) + if service["state"] != "работает": + downtime += (service_end_time - service_start_time).total_seconds() + + return downtime + + @staticmethod + def get_service_times(service: dict, start_time: datetime, end_time: datetime) -> (datetime, datetime): + """ + Получение временных меток начала и конца для сервиса. + """ + service_end_time = service.get("timestamp_end", end_time) + if isinstance(service_end_time, str): + service_end_time = datetime.fromisoformat(service_end_time) + service_start_time = service["timestamp"] + if isinstance(service_start_time, str): + service_start_time = datetime.fromisoformat(service_start_time) + + 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 index 01d46e0..fbe6eba 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,93 +1,103 @@ -from sanic import response +# app/routes.py + +from sanic import Blueprint, response from sanic.request import Request -from . import app, collection -from .models import Service from .exceptions import NotFound, ServerError from loguru import logger from sanic_ext import openapi from typing import Optional +from .models import Service + +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}") -@app.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 = await Service.create_or_update(request.app.ctx.collection, data) + return response.json(service.to_dict(), status=201) - service = await Service.create_or_update(collection, data) - return response.json(service.to_dict(), status=201) + 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") - 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") + @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}") -@app.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 = await Service.create_or_update(request.app.ctx.collection, data) + return response.json(service.to_dict(), status=200) - service = await Service.create_or_update(collection, data) - return response.json(service.to_dict(), status=200) + 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") - 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") + @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 = await Service.get_history(request.app.ctx.collection, name) + return response.json({"history": [service.to_dict() for service in services]}) + 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}") -@app.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 = await Service.get_history(collection, name) - return response.json({"history": [service.to_dict() for service in services]}) - 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}") + @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 = await Service.get_all(request.app.ctx.collection) + return response.json({"services": [service.to_dict() for service in services]}) + except Exception: + logger.exception("Failed to fetch services") + raise ServerError("Failed to fetch services") -@app.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 = await Service.get_all(collection) - return response.json({"services": [service.to_dict() for service in services]}) - except Exception: - logger.exception("Failed to fetch services") - raise ServerError("Failed to fetch 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 = await Service.calculate_sla(request.app.ctx.collection, name, interval) + return response.json(result) + except Exception as e: + logger.exception(f"Failed to calculate SLA for {name}") + raise ServerError(f"Failed to calculate SLA for {name}") -@app.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 = await Service.calculate_sla(collection, name, interval) - return response.json(result) - except Exception as e: - logger.exception(f"Failed to calculate SLA for {name}") - raise ServerError(f"Failed to calculate SLA for {name}") + @staticmethod + def register_routes(app, collection): + app.blueprint(bp) + app.ctx.collection = collection diff --git a/app/serializers.py b/app/serializers.py index 7336b2e..656a7b6 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -3,7 +3,6 @@ from typing import Optional from bson import ObjectId - class ServiceSerializer(BaseModel): id: Optional[str] = None name: str @@ -13,16 +12,16 @@ class ServiceSerializer(BaseModel): timestamp_end: Optional[datetime] = None class Config: - from_attributes = True json_encoders = { datetime: lambda v: v.isoformat(), ObjectId: lambda v: str(v) } - def dict(self, **kwargs): - data = super().dict(**kwargs) - if "timestamp" in data and data["timestamp"]: - data["timestamp"] = data["timestamp"].isoformat() - if "timestamp_end" in data and data["timestamp_end"]: - data["timestamp_end"] = data["timestamp_end"].isoformat() + def to_mongo_dict(self) -> dict: + """ + Преобразование объекта в словарь для MongoDB. + """ + data = self.dict(exclude_none=True) + if 'id' in data: + data['_id'] = ObjectId(data.pop('id')) return data diff --git a/requirements.txt b/requirements.txt index e6b1982..fdd950e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,16 +3,26 @@ annotated-types==0.7.0 attrs==23.2.0 certifi==2024.6.2 charset-normalizer==3.3.2 +debugpy==1.8.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 loguru==0.7.2 +mongoengine==0.28.2 motor==3.5.0 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 From a20ddbcabc64fb0060b77db687b35c832e5750a9 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 14:38:56 +0500 Subject: [PATCH 08/18] change README --- app/models.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/models.py b/app/models.py index 5d7a6b3..f40310c 100644 --- a/app/models.py +++ b/app/models.py @@ -46,7 +46,7 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict) -> "Service": + def from_dict(cls, data: dict) -> Service: """ Создание объекта сервиса из словаря. """ @@ -61,7 +61,7 @@ def from_dict(cls, data: dict) -> "Service": ) @classmethod - async def get_all(cls, collection: AsyncIOMotorCollection) -> List["Service"]: + async def get_all(cls, collection: AsyncIOMotorCollection) -> List[Service]: """ Получение всех сервисов из коллекции. """ @@ -71,7 +71,7 @@ async def get_all(cls, collection: AsyncIOMotorCollection) -> List["Service"]: return services @classmethod - async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> "Service": + async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> Service: """ Создание нового или обновление существующего сервиса. """ @@ -91,7 +91,7 @@ async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[s return await cls.create_new_service(collection, data) @classmethod - async def create_new_service(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> "Service": + async def create_new_service(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> Service: """ Создание нового сервиса. """ @@ -101,7 +101,7 @@ async def create_new_service(cls, collection: AsyncIOMotorCollection, data: Dict return service @classmethod - async def update_service_state(cls, collection: AsyncIOMotorCollection, existing_service: dict, new_state: str, description: Optional[str]) -> "Service": + async def update_service_state(cls, collection: AsyncIOMotorCollection, existing_service: dict, new_state: str, description: Optional[str]) -> Service: """ Обновление состояния существующего сервиса. """ @@ -130,7 +130,7 @@ async def update_end_timestamp(cls, collection: AsyncIOMotorCollection, name: st return result.modified_count @classmethod - async def get_history(cls, collection: AsyncIOMotorCollection, name: str) -> List["Service"]: + async def get_history(cls, collection: AsyncIOMotorCollection, name: str) -> List[Service]: """ Получение истории изменения состояния сервиса. """ @@ -198,7 +198,7 @@ async def calculate_downtime(cls, collection: AsyncIOMotorCollection, name: str, return downtime @staticmethod - def get_service_times(service: dict, start_time: datetime, end_time: datetime) -> (datetime, datetime): + def get_service_times(service: dict, start_time: datetime, end_time: datetime) -> tuple[datetime, datetime]: """ Получение временных меток начала и конца для сервиса. """ From f40c643118aacb710fa100efa6c642616d0a16bb Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 14:40:04 +0500 Subject: [PATCH 09/18] change README --- app/__init__.py | 13 ++++++++++--- app/db.py | 11 +++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 0968bcd..36f9de0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,10 @@ +# app/__init__.py + from sanic import Sanic from sanic_ext import Extend -from .db import init_db +from .db import initialize_db from .routes import ServiceRoutes +import os app = Sanic("ServiceAPI") @@ -12,6 +15,10 @@ "description": "API для управления сервисами", }) -init_db() +# Абсолютный путь к файлу config.py +config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../config.py') +app.config.update_config(config_path) + +initialize_db(app) -ServiceRoutes.register_routes(app) +ServiceRoutes.register_routes(app, app.ctx.collection) diff --git a/app/db.py b/app/db.py index e3d9721..a1bdf91 100644 --- a/app/db.py +++ b/app/db.py @@ -1,7 +1,6 @@ -from mongoengine import connect -import os +from motor.motor_asyncio import AsyncIOMotorClient -def init_db(): - db_name = os.getenv("DATABASE_NAME", "service_db") - db_host = os.getenv("MONGODB_URL", "mongodb://localhost:27017/service_db") - connect(db=db_name, host=db_host) +def initialize_db(app): + client = AsyncIOMotorClient(app.config.MONGODB_URL, serverSelectionTimeoutMS=50000, socketTimeoutMS=50000) + app.ctx.db = client[app.config.DATABASE_NAME] + app.ctx.collection = app.ctx.db[app.config.COLLECTION_NAME] From b725cbe48ebcfa27159e769ce015e48187470f82 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 14:57:41 +0500 Subject: [PATCH 10/18] chande mongo --- app/__init__.py | 14 ++---- app/db.py | 11 +++-- app/models.py | 120 +++++++++++++++++------------------------------- app/routes.py | 22 ++++----- 4 files changed, 64 insertions(+), 103 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 36f9de0..3bff63d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,10 +1,6 @@ -# app/__init__.py - from sanic import Sanic from sanic_ext import Extend -from .db import initialize_db -from .routes import ServiceRoutes -import os +from .db import init_db app = Sanic("ServiceAPI") @@ -15,10 +11,8 @@ "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() -initialize_db(app) +from .routes import ServiceRoutes -ServiceRoutes.register_routes(app, app.ctx.collection) +ServiceRoutes.register_routes(app) diff --git a/app/db.py b/app/db.py index a1bdf91..e3d9721 100644 --- a/app/db.py +++ b/app/db.py @@ -1,6 +1,7 @@ -from motor.motor_asyncio import AsyncIOMotorClient +from mongoengine import connect +import os -def initialize_db(app): - client = AsyncIOMotorClient(app.config.MONGODB_URL, serverSelectionTimeoutMS=50000, socketTimeoutMS=50000) - app.ctx.db = client[app.config.DATABASE_NAME] - app.ctx.collection = app.ctx.db[app.config.COLLECTION_NAME] +def init_db(): + db_name = os.getenv("DATABASE_NAME", "service_db") + db_host = os.getenv("MONGODB_URL", "mongodb://localhost:27017/service_db") + connect(db=db_name, host=db_host) diff --git a/app/models.py b/app/models.py index f40310c..c638fb0 100644 --- a/app/models.py +++ b/app/models.py @@ -1,27 +1,20 @@ -# app/models.py -from __future__ import annotations -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any -from motor.motor_asyncio import AsyncIOMotorCollection -from bson import ObjectId - -from .exceptions import NotFound, ServerError # Изменен импорт +from datetime import datetime +from mongoengine import Document, StringField, DateTimeField, ObjectIdField +from .exceptions import NotFound, ServerError VALID_STATES = ["работает", "не работает"] -class Service: + +class Service(Document): """ Класс, представляющий сервис с его состоянием и временными метками. """ - - def __init__(self, name: str, state: str, id: Optional[str] = None, description: Optional[str] = None, - timestamp: Optional[datetime] = None, timestamp_end: Optional[datetime] = None): - self.id = id or str(ObjectId()) - self.name = name - self.state = self.validate_state(state) - self.description = description - self.timestamp = timestamp or datetime.utcnow() - self.timestamp_end = timestamp_end + id = ObjectIdField(primary_key=True, default=None) + name = StringField(required=True) + state = StringField(required=True, choices=VALID_STATES) + description = StringField() + timestamp = DateTimeField(default=datetime.utcnow) + timestamp_end = DateTimeField() @staticmethod def validate_state(state: str) -> str: @@ -37,7 +30,7 @@ def to_dict(self) -> dict: Преобразование объекта сервиса в словарь. """ return { - "_id": self.id, + "id": str(self.id), "name": self.name, "state": self.state, "description": self.description, @@ -46,32 +39,30 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict) -> Service: + def from_dict(cls, data: dict) -> "Service": """ Создание объекта сервиса из словаря. """ return cls( - id=str(data.get("_id")), + id=data.get("id"), name=data["name"], state=data["state"], description=data.get("description"), - timestamp=datetime.fromisoformat(data["timestamp"]) if isinstance(data["timestamp"], str) else data["timestamp"], + timestamp=datetime.fromisoformat(data["timestamp"]) if isinstance(data["timestamp"], str) else data[ + "timestamp"], timestamp_end=datetime.fromisoformat(data["timestamp_end"]) if data.get("timestamp_end") and isinstance( data["timestamp_end"], str) else data["timestamp_end"] ) @classmethod - async def get_all(cls, collection: AsyncIOMotorCollection) -> List[Service]: + def get_all(cls) -> List["Service"]: """ Получение всех сервисов из коллекции. """ - services = [] - async for document in collection.find(): - services.append(cls.from_dict(document)) - return services + return list(cls.objects) @classmethod - async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> Service: + def create_or_update(cls, data: Dict[str, Any]) -> "Service": """ Создание нового или обновление существующего сервиса. """ @@ -82,67 +73,48 @@ async def create_or_update(cls, collection: AsyncIOMotorCollection, data: Dict[s if not name or not new_state: raise ValueError("Name and state are required") - await cls.update_end_timestamp(collection, name) - existing_service = await collection.find_one({"name": name, "timestamp_end": None}) + existing_service = cls.objects(name=name, timestamp_end=None).first() if existing_service: - return await cls.update_service_state(collection, existing_service, new_state, description) + return cls.update_service_state(existing_service, new_state, description) else: - return await cls.create_new_service(collection, data) + return cls.create_new_service(data) @classmethod - async def create_new_service(cls, collection: AsyncIOMotorCollection, data: Dict[str, Any]) -> Service: + def create_new_service(cls, data: Dict[str, Any]) -> "Service": """ Создание нового сервиса. """ service = cls(**data) - result = await collection.insert_one(service.to_dict()) - service.id = str(result.inserted_id) + service.save() return service @classmethod - async def update_service_state(cls, collection: AsyncIOMotorCollection, existing_service: dict, new_state: str, description: Optional[str]) -> Service: + def update_service_state(cls, existing_service: Document, 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.state == new_state: + raise ValueError(f"Service {existing_service.name} is already in state {new_state}") - await collection.update_one( - {"_id": existing_service["_id"]}, - {"$set": {"timestamp_end": datetime.utcnow()}} - ) + existing_service.update(set__timestamp_end=datetime.utcnow()) - service = cls(name=existing_service["name"], state=new_state, description=description) - result = await collection.insert_one(service.to_dict()) - service.id = str(result.inserted_id) + service = cls(name=existing_service.name, state=new_state, description=description) + service.save() return service @classmethod - async def update_end_timestamp(cls, collection: AsyncIOMotorCollection, name: str) -> int: - """ - Обновление временной метки завершения для всех записей сервиса с незавершенным состоянием. - """ - result = await collection.update_many( - {"name": name, "timestamp_end": None}, - {"$set": {"timestamp_end": datetime.utcnow()}} - ) - return result.modified_count - - @classmethod - async def get_history(cls, collection: AsyncIOMotorCollection, name: str) -> List[Service]: + def get_history(cls, name: str) -> List["Service"]: """ Получение истории изменения состояния сервиса. """ - services = [] - async for document in collection.find({"name": name}): - services.append(cls.from_dict(document)) + services = list(cls.objects(name=name)) if not services: raise NotFound(f"No service found with name {name}") return services @classmethod - async def calculate_sla(cls, collection: AsyncIOMotorCollection, name: str, interval: str) -> Dict[str, Any]: + def calculate_sla(cls, name: str, interval: str) -> Dict[str, Any]: """ Расчет SLA (Service Level Agreement) для сервиса за указанный интервал времени. """ @@ -152,14 +124,14 @@ async def calculate_sla(cls, collection: AsyncIOMotorCollection, name: str, inte start_time = end_time - timedelta(seconds=interval_seconds) total_time = interval_seconds - downtime = await cls.calculate_downtime(collection, name, start_time, end_time) + downtime = cls.calculate_downtime(name, start_time, end_time) uptime = total_time - downtime sla = (uptime / total_time) * 100 return {"sla": round(sla, 3), "downtime": round(downtime / 3600, 3)} except NotFound as e: - raise e + raise NotFound(f"No service found with name {name}") except Exception as e: raise ServerError("Failed to calculate SLA") @@ -176,38 +148,32 @@ def parse_interval(interval: str) -> int: raise ValueError('Invalid interval format. Use "h" for hours or "d" for days.') @classmethod - async def calculate_downtime(cls, collection: AsyncIOMotorCollection, name: str, start_time: datetime, end_time: datetime) -> int: + def calculate_downtime(cls, name: str, start_time: datetime, end_time: datetime) -> int: """ Расчет времени простоя сервиса. """ - service_exists = await collection.find_one({"name": name}) + service_exists = cls.objects(name=name).first() if not service_exists: raise NotFound(f"No service found with name {name}") - service_entries = await collection.find({"name": name, "$or": [ - {"timestamp": {"$gte": start_time, "$lt": end_time}}, - {"timestamp_end": {"$gte": start_time, "$lt": end_time}} - ]}).sort("timestamp").to_list(length=None) + service_entries = cls.objects(name=name, timestamp__gte=start_time, timestamp__lt=end_time) | \ + cls.objects(name=name, timestamp_end__gte=start_time, timestamp_end__lt=end_time) downtime = 0 for service in service_entries: service_start_time, service_end_time = cls.get_service_times(service, start_time, end_time) - if service["state"] != "работает": + if service.state != "работает": downtime += (service_end_time - service_start_time).total_seconds() return downtime @staticmethod - def get_service_times(service: dict, start_time: datetime, end_time: datetime) -> tuple[datetime, datetime]: + def get_service_times(service: Document, start_time: datetime, end_time: datetime) -> (datetime, datetime): """ Получение временных меток начала и конца для сервиса. """ - service_end_time = service.get("timestamp_end", end_time) - if isinstance(service_end_time, str): - service_end_time = datetime.fromisoformat(service_end_time) - service_start_time = service["timestamp"] - if isinstance(service_start_time, str): - service_start_time = datetime.fromisoformat(service_start_time) + service_end_time = service.timestamp_end or end_time + service_start_time = service.timestamp if service_start_time < start_time: service_start_time = start_time diff --git a/app/routes.py b/app/routes.py index fbe6eba..721226a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,5 +1,3 @@ -# app/routes.py - from sanic import Blueprint, response from sanic.request import Request from .exceptions import NotFound, ServerError @@ -22,7 +20,7 @@ async def add_service(request: Request): data = request.json logger.info(f"Received data: {data}") - service = await Service.create_or_update(request.app.ctx.collection, data) + service = Service.create_or_update(data) return response.json(service.to_dict(), status=201) except ValueError as e: @@ -43,7 +41,7 @@ async def update_service(request: Request, name: str): data["name"] = name logger.info(f"Received data for update: {data}") - service = await Service.create_or_update(request.app.ctx.collection, data) + service = Service.create_or_update(data) return response.json(service.to_dict(), status=200) except ValueError as e: @@ -62,14 +60,14 @@ async def update_service(request: Request, name: str): @openapi.response(200, {"application/json": {"history": list}}) async def get_service_history(request: Request, name: str): try: - services = await Service.get_history(request.app.ctx.collection, name) + services = Service.get_history(name) return response.json({"history": [service.to_dict() for service in services]}) 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}") + raise ServerError(f"Failed to fetch service history for {name}") @bp.get("/services") @openapi.summary("Get all services") @@ -77,9 +75,9 @@ async def get_service_history(request: Request, name: str): @openapi.response(200, {"application/json": {"services": list}}) async def get_services(request: Request): try: - services = await Service.get_all(request.app.ctx.collection) + services = Service.get_all() return response.json({"services": [service.to_dict() for service in services]}) - except Exception: + except Exception as e: logger.exception("Failed to fetch services") raise ServerError("Failed to fetch services") @@ -91,13 +89,15 @@ async def get_services(request: Request): async def get_service_sla(request: Request, name: str): interval = request.args.get("interval") try: - result = await Service.calculate_sla(request.app.ctx.collection, name, interval) + result = Service.calculate_sla(name, interval) return response.json(result) + 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 calculate SLA for {name}") raise ServerError(f"Failed to calculate SLA for {name}") @staticmethod - def register_routes(app, collection): + def register_routes(app): app.blueprint(bp) - app.ctx.collection = collection From 8e74ab581ef588fb1125a97e0faf8e06fa3ebca3 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 17:12:45 +0500 Subject: [PATCH 11/18] change structure start, change driver to enginemongo and add history state for services --- app/__init__.py | 12 +++- app/db.py | 7 +-- app/models.py | 134 +++++++++++++++++++-------------------------- app/routes.py | 7 +-- docker-compose.yml | 33 ++++++----- 5 files changed, 84 insertions(+), 109 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 3bff63d..c6639f8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,8 @@ from sanic import Sanic from sanic_ext import Extend -from .db import init_db +import os +from app.routes import ServiceRoutes +from app.db import init_db app = Sanic("ServiceAPI") @@ -11,8 +13,12 @@ "description": "API для управления сервисами", }) -init_db() +# Абсолютный путь к файлу config.py +config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../config.py') +app.config.update_config(config_path) -from .routes import ServiceRoutes +# Инициализация базы данных +init_db(app.config.MONGODB_URL, app.config.DATABASE_NAME) +# Регистрация маршрутов ServiceRoutes.register_routes(app) diff --git a/app/db.py b/app/db.py index e3d9721..cb67c83 100644 --- a/app/db.py +++ b/app/db.py @@ -1,7 +1,4 @@ from mongoengine import connect -import os -def init_db(): - db_name = os.getenv("DATABASE_NAME", "service_db") - db_host = os.getenv("MONGODB_URL", "mongodb://localhost:27017/service_db") - connect(db=db_name, host=db_host) +def init_db(uri: str, db_name: str): + connect(db_name, host=uri) diff --git a/app/models.py b/app/models.py index c638fb0..54e5290 100644 --- a/app/models.py +++ b/app/models.py @@ -1,34 +1,34 @@ -from datetime import datetime -from mongoengine import Document, StringField, DateTimeField, ObjectIdField +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=None) + 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() - - @staticmethod - def validate_state(state: str) -> str: - """ - Проверка допустимости состояния сервиса. - """ - if state not in VALID_STATES: - raise ValueError(f"Invalid state: {state}. State must be one of {VALID_STATES}") - return state + history = ListField(EmbeddedDocumentField(StateHistory), default=list) def to_dict(self) -> dict: - """ - Преобразование объекта сервиса в словарь. - """ return { "id": str(self.id), "name": self.name, @@ -36,36 +36,15 @@ def to_dict(self) -> dict: "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 from_dict(cls, data: dict) -> "Service": - """ - Создание объекта сервиса из словаря. - """ - return cls( - id=data.get("id"), - name=data["name"], - state=data["state"], - description=data.get("description"), - timestamp=datetime.fromisoformat(data["timestamp"]) if isinstance(data["timestamp"], str) else data[ - "timestamp"], - timestamp_end=datetime.fromisoformat(data["timestamp_end"]) if data.get("timestamp_end") and isinstance( - data["timestamp_end"], str) else data["timestamp_end"] - ) - - @classmethod - def get_all(cls) -> List["Service"]: - """ - Получение всех сервисов из коллекции. - """ + def get_all(cls) -> List[Service]: return list(cls.objects) @classmethod - def create_or_update(cls, data: Dict[str, Any]) -> "Service": - """ - Создание нового или обновление существующего сервиса. - """ + def create_or_update(cls, data: Dict[str, Any]) -> Service: name = data.get("name") new_state = data.get("state") description = data.get("description") @@ -73,7 +52,7 @@ def create_or_update(cls, data: Dict[str, Any]) -> "Service": if not name or not new_state: raise ValueError("Name and state are required") - existing_service = cls.objects(name=name, timestamp_end=None).first() + existing_service = cls.objects(name=name).first() if existing_service: return cls.update_service_state(existing_service, new_state, description) @@ -81,33 +60,34 @@ def create_or_update(cls, data: Dict[str, Any]) -> "Service": return cls.create_new_service(data) @classmethod - def create_new_service(cls, data: Dict[str, Any]) -> "Service": - """ - Создание нового сервиса. - """ + 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: Document, new_state: str, description: Optional[str]) -> "Service": - """ - Обновление состояния существующего сервиса. - """ + 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}") - existing_service.update(set__timestamp_end=datetime.utcnow()) + # Закрываем текущую запись в истории + if existing_service.history: + existing_service.history[-1].timestamp_end = datetime.utcnow() - service = cls(name=existing_service.name, state=new_state, description=description) - service.save() - return service + # Добавляем новую запись в историю + 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"]: - """ - Получение истории изменения состояния сервиса. - """ + 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}") @@ -115,9 +95,6 @@ def get_history(cls, name: str) -> List["Service"]: @classmethod def calculate_sla(cls, name: str, interval: str) -> Dict[str, Any]: - """ - Расчет SLA (Service Level Agreement) для сервиса за указанный интервал времени. - """ try: interval_seconds = cls.parse_interval(interval) end_time = datetime.utcnow() @@ -137,9 +114,6 @@ def calculate_sla(cls, name: str, interval: str) -> Dict[str, Any]: @staticmethod def parse_interval(interval: str) -> int: - """ - Преобразование интервала времени в секунды. - """ if interval.endswith("h"): return int(interval[:-1]) * 3600 elif interval.endswith("d"): @@ -149,31 +123,33 @@ def parse_interval(interval: str) -> int: @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, timestamp__gte=start_time, timestamp__lt=end_time) | \ - cls.objects(name=name, timestamp_end__gte=start_time, timestamp_end__lt=end_time) + 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: - service_start_time, service_end_time = cls.get_service_times(service, start_time, end_time) - if service.state != "работает": - downtime += (service_end_time - service_start_time).total_seconds() + 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(service: Document, start_time: datetime, end_time: datetime) -> (datetime, datetime): - """ - Получение временных меток начала и конца для сервиса. - """ - service_end_time = service.timestamp_end or end_time - service_start_time = service.timestamp + 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 diff --git a/app/routes.py b/app/routes.py index 721226a..3efea86 100644 --- a/app/routes.py +++ b/app/routes.py @@ -67,7 +67,7 @@ async def get_service_history(request: Request, name: str): return response.json({"error": str(e)}, status=404) except Exception as e: logger.exception(f"Failed to fetch service history for {name}") - raise ServerError(f"Failed to fetch service history for {name}") + raise ServerError("Failed to fetch service history for {name}") @bp.get("/services") @openapi.summary("Get all services") @@ -77,7 +77,7 @@ async def get_services(request: Request): try: services = Service.get_all() return response.json({"services": [service.to_dict() for service in services]}) - except Exception as e: + except Exception: logger.exception("Failed to fetch services") raise ServerError("Failed to fetch services") @@ -91,9 +91,6 @@ async def get_service_sla(request: Request, name: str): try: result = Service.calculate_sla(name, interval) return response.json(result) - 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 calculate SLA for {name}") raise ServerError(f"Failed to calculate SLA for {name}") diff --git a/docker-compose.yml b/docker-compose.yml index 101f4a1..4df1098 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,26 @@ version: '3.8' services: - - web: - build: . - container_name: service_api - command: python manage.py - volumes: - - .:/code - ports: - - "8000:8000" - depends_on: - - mongo - mongo: - image: mongo:4.4 + image: mongo:latest container_name: mongo ports: - "27017:27017" volumes: - - mongo_data:/data/db - -volumes: - mongo_data: + - 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: From 29043f5ed154c8fcf2fda5daa2ffafc05d36f99d Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 17:30:21 +0500 Subject: [PATCH 12/18] clean trash --- app/__init__.py | 4 +++- app/exceptions.py | 2 -- app/loggers.py | 9 +++++++++ app/models.py | 12 ++++++------ app/routes.py | 31 ++++++++++++++++++++----------- app/serializers.py | 4 +--- 6 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 app/loggers.py diff --git a/app/__init__.py b/app/__init__.py index c6639f8..9fe4f16 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,9 +4,9 @@ from app.routes import ServiceRoutes from app.db import init_db + app = Sanic("ServiceAPI") -# Настройка Sanic Extensions и OpenAPI с использованием Swagger UI Extend(app, openapi_config={ "title": "Service API", "version": "1.0.0", @@ -22,3 +22,5 @@ # Регистрация маршрутов ServiceRoutes.register_routes(app) + + diff --git a/app/exceptions.py b/app/exceptions.py index 57fd962..0e82f10 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -1,5 +1,3 @@ -# app/exceptions.py - from sanic.exceptions import SanicException from sanic import response 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 index 54e5290..cda6016 100644 --- a/app/models.py +++ b/app/models.py @@ -1,12 +1,14 @@ 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 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) @@ -19,6 +21,7 @@ def to_dict(self) -> dict: "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) @@ -71,14 +74,11 @@ def update_service_state(cls, existing_service: Service, new_state: str, descrip 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() @@ -105,13 +105,13 @@ def calculate_sla(cls, name: str, interval: str) -> Dict[str, Any]: uptime = total_time - downtime sla = (uptime / total_time) * 100 - - return {"sla": round(sla, 3), "downtime": round(downtime / 3600, 3)} 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"): diff --git a/app/routes.py b/app/routes.py index 3efea86..7260886 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,13 +1,17 @@ +from mongoengine import ValidationError from sanic import Blueprint, response from sanic.request import Request from .exceptions import NotFound, ServerError -from loguru import logger 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") @@ -19,10 +23,10 @@ async def add_service(request: Request): try: data = request.json logger.info(f"Received data: {data}") - service = Service.create_or_update(data) - return response.json(service.to_dict(), status=201) - + 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) @@ -30,6 +34,8 @@ async def add_service(request: Request): 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") @@ -40,10 +46,7 @@ async def update_service(request: Request, name: str): data = request.json data["name"] = name logger.info(f"Received data for update: {data}") - service = Service.create_or_update(data) - return response.json(service.to_dict(), status=200) - except ValueError as e: logger.error(f"Validation error: {e}") return response.json({"error": str(e)}, status=400) @@ -54,6 +57,8 @@ async def update_service(request: Request, name: str): 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") @@ -61,7 +66,6 @@ async def update_service(request: Request, name: str): async def get_service_history(request: Request, name: str): try: services = Service.get_history(name) - return response.json({"history": [service.to_dict() for service in services]}) except NotFound as e: logger.error(f"Service not found: {name}") return response.json({"error": str(e)}, status=404) @@ -69,6 +73,8 @@ async def get_service_history(request: Request, name: str): 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") @@ -76,25 +82,28 @@ async def get_service_history(request: Request, name: str): async def get_services(request: Request): try: services = Service.get_all() - return response.json({"services": [service.to_dict() for service in services]}) 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.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) - return response.json(result) 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 index 656a7b6..422d612 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -3,6 +3,7 @@ from typing import Optional from bson import ObjectId + class ServiceSerializer(BaseModel): id: Optional[str] = None name: str @@ -18,9 +19,6 @@ class Config: } def to_mongo_dict(self) -> dict: - """ - Преобразование объекта в словарь для MongoDB. - """ data = self.dict(exclude_none=True) if 'id' in data: data['_id'] = ObjectId(data.pop('id')) From 41a19efffbf8421524e4de24e368192821ecbf87 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Thu, 4 Jul 2024 21:16:58 +0500 Subject: [PATCH 13/18] clean trash --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index fdd950e..7231897 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ annotated-types==0.7.0 attrs==23.2.0 certifi==2024.6.2 charset-normalizer==3.3.2 -debugpy==1.8.2 dnspython==2.6.1 html5tagger==1.3.0 httptools==0.6.1 @@ -11,9 +10,7 @@ idna==3.7 inflection==0.5.1 jsonschema==4.22.0 jsonschema-specifications==2023.12.1 -loguru==0.7.2 mongoengine==0.28.2 -motor==3.5.0 multidict==6.0.5 openapi==2.0.0 pydantic==2.7.4 From c338ccb0b29a8fe66f35959d880be1fe8a90e685 Mon Sep 17 00:00:00 2001 From: GabagulL Date: Fri, 5 Jul 2024 12:25:08 +0500 Subject: [PATCH 14/18] merge conflicts --- README.md | 2 +- task-python | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 160000 task-python diff --git a/README.md b/README.md index c869246..b44478a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -<<<<<<< HEAD # Service API Этот проект предоставляет API для управления и мониторинга состояния различных сервисов. API позволяет: @@ -16,6 +15,7 @@ ```bash git clone https://github.com/AlexYrlv/service-api.git cd service-api + ``` 2. Запустите контейнеры diff --git a/task-python b/task-python new file mode 160000 index 0000000..16262d2 --- /dev/null +++ b/task-python @@ -0,0 +1 @@ +Subproject commit 16262d2bbd07d797d2a2701c85b6df5f7914b1a1 From 6e30a22fce18d2db46d8ff6b15b9599ae47b185d Mon Sep 17 00:00:00 2001 From: GabagulL Date: Fri, 5 Jul 2024 12:30:04 +0500 Subject: [PATCH 15/18] rm debug --- manage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manage.py b/manage.py index 89d085b..e560f56 100644 --- a/manage.py +++ b/manage.py @@ -2,4 +2,4 @@ if __name__ == '__main__': - app.run(host='0.0.0.0', port=8000, debug=True) + app.run(host='0.0.0.0', port=8000) From 868f6343da0c4b5bfbd43881f22fd5b5777b0e9a Mon Sep 17 00:00:00 2001 From: GabagulL Date: Fri, 5 Jul 2024 12:49:28 +0500 Subject: [PATCH 16/18] merge conflicts --- task-python | 1 - 1 file changed, 1 deletion(-) delete mode 160000 task-python diff --git a/task-python b/task-python deleted file mode 160000 index 16262d2..0000000 --- a/task-python +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 16262d2bbd07d797d2a2701c85b6df5f7914b1a1 From 59e8b0c5c3c464168a84a134605f47e85c993a4c Mon Sep 17 00:00:00 2001 From: Aleksandr Yurlov <108286754+AlexYrlv@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:02:07 +0500 Subject: [PATCH 17/18] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b44478a..60eafec 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 1. Клонируйте репозиторий: ```bash - git clone https://github.com/AlexYrlv/service-api.git + git clone https://github.com/AlexYrlv/task-python.git cd service-api ``` @@ -29,4 +29,4 @@ ```bash http://localhost:8000/docs/swagger - ``` \ No newline at end of file + ``` From 149504e4da19893aad9aa948822da8dcc655d361 Mon Sep 17 00:00:00 2001 From: Aleksandr Yurlov <108286754+AlexYrlv@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:02:41 +0500 Subject: [PATCH 18/18] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 60eafec..abf30fb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ```bash git clone https://github.com/AlexYrlv/task-python.git - cd service-api + cd serviceс-api ``` 2. Запустите контейнеры