diff --git a/.env b/.env new file mode 100644 index 0000000..d17514f --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DB_URL="postgresql+asyncpg://postgres:rootroot@localhost/itpc_test" \ No newline at end of file diff --git a/.env-non-dev b/.env-non-dev new file mode 100644 index 0000000..fc15e7d --- /dev/null +++ b/.env-non-dev @@ -0,0 +1 @@ +DB_URL="postgresql+asyncpg://postgres:mypassword@database/postgres" \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..20c85e2 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,37 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bbbba04 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ec5e7af --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/task-python.iml b/.idea/task-python.iml new file mode 100644 index 0000000..5d8ff06 --- /dev/null +++ b/.idea/task-python.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/toolchains.xml b/.idea/toolchains.xml new file mode 100644 index 0000000..4581420 --- /dev/null +++ b/.idea/toolchains.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e46acd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10 + +RUN mkdir /fast_app + +WORKDIR /fast_app + +COPY requirements.txt . + +RUN pip install -r requirements.txt + +COPY . . + + + +CMD gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind=0.0.0.0:8000 \ No newline at end of file diff --git a/README.md b/README.md index cfb476f..bdaa22c 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,5 @@ # Вакансия :: Программист Python - -Разработка бизнес-системы с использованием веб-технологий. Автоматизация сервисов с большим количеством пользователей - -## От вас - -### Обязательно - -- Знание синтаксиса языка Python -- Опыт разработки на Python не менее 1 года -- Базовые знания принципов работы Web -- Желание работать в команде и развиваться - -### Приветствуется - -- Навыки работы с Flask, Sanic, FastAPI -- Опыт работы с БД: PostgreSQL, MS SQL, MongoDB, ClickHouse -- Опыт разработки под ОС семейства GNU Linux, знание основных команд -- Работа с системами управления исходным кодом Git -- Знания базовых принципов разработки (тестирование, рефакторинг, Code Review, CI/CD) - -### Будет круто, но не обязательно - -- Знание английского языка на уровне чтения технической документации -- Участие в разработке Open Source проектов -- Наличие профиля на GitHub, Stack Overflow -- Наличие проектов которые можете показать нам -- Разработка с использованием TypeScript, знание современных frontend-библиотек и подходов к разработке - -## У нас - -- Полный рабочий день, гибкий обед и начало рабочего дня -- Полностью «белая» заработная плата с возможностью увеличения в процессе работы (зависит от отдачи сотрудника) -- Полис ДМС -- Дружелюбная команда с юмором, готовая поддержать и помочь -- Интересный проект и необычные задачи. Рутина тоже есть, но мы нацелены именно на продуктив -- Возможность одновременно участвовать в разных проектах и развивать другие компетенции (TS и все модное) -- Попробовать современные тренды и практики в разработке ПО -- Никаких опенспейсов и кубиклов, а комфортное пространство в центре Тюмени -- Готовы делиться опытом и знаниями, если вы готовы их получать - -  - -Если вакансия вас заинтересовала, но есть недопонимания и вопросы, свяжитесь с нами - обсудим, договоримся. -Большим плюсом будет выполнение тестового задания. -Если у вас есть опыт работы с 1С, то эта вакансия не для вас. - +## (Для корректной работы приложения - состояние/status сервиса необходимо прописывать как up/down/unstable) ## Тестовое задание Решение принимается в виде PR к текущему проекту. @@ -53,7 +8,7 @@ Требуется написать API который: -1. Получает и сохраняет данные: имя, состояние, описание +1. Получает и сохраняет данные: имя, состояние, описание 2. Выводит список сервисов с актуальным состоянием 3. По имени сервиса выдает историю изменения состояния и все данные по каждому состоянию @@ -62,3 +17,30 @@ 1. По указанному интервалу выдается информация о том сколько не работал сервис и считать SLA в процентах до 3-й запятой Вывод всех данных должен быть в формате JSON + +## Стек +- ⚡ [**FastAPI**](https://fastapi.tiangolo.com) Для создания API/backend на Python. + - 🧰 [SQLAlchemy](https://www.sqlalchemy.org/) Для взаимодействия с базой данных (ORM). + - 🔍 [Pydantic](https://docs.pydantic.dev), используется FastAPI для валидации данных и управления настройками. + - 💾 [PostgreSQL](https://www.postgresql.org) в качестве SQL БД. + - :unicorn: [Uvicorn](https://www.uvicorn.org/), Веб-сервер ASGI для Python. (при запуске приложения/сервиса через docker используется gunicorn) + - [pip](https://pip.pypa.io/en/stable/) в качестве стандартного пакетного менеджера. ![Зависимости зафиксированы](https://img.shields.io/badge/зависимости_зафиксированы-using%20pip%20freeze-blue) + - [yapf](https://github.com/google/yapf) для автоматического форматирования кода в проекте. + - 🐋[Docker](https://www.docker.com/) Запуск сервиса и инфраструктуры проводится в docker контейнерах. + + +## Инструкция по запуску сервиса с помощью 🐋Docker🐋 +### 1. Клонируйте репозиторий + bash команда: git clone https://github.com/Telmann/task-python.git +### 2. Далее для запуска приложения + выполните: docker compose up --build app +### 3. Готово! Приложение будет доступно по адресу: http://127.0.0.1:9990/docs#/ + +## Интерактивная документация по API +![image](https://github.com/user-attachments/assets/6872f860-be9e-4e6b-90dd-79f1513d65aa) + +## Кратко про существующие эндпоинты: + - POST /services: Создает/обновляет информацию о сервисе в базе данных. При успешном выполнении возвращает сообщение об успешном создании/обновлении. + - GET /services: Получает список всех сервисов из базы данных. Если в БД нет сервисов на данный момент, возвращает сообщение о том, что в БД нет сервисов. + - GET /services/{service_name}/history: Получает историю изменений для указанного сервиса по его имени. Если сервис не найден/у него нет истории, возвращает сообщение об этом. + - GET /services/{service_id}/SLA_downtime_info: Рассчитывает время даунтайма для указанного сервиса (по id) в заданном временном диапазоне. Возвращает данные о времени даунтайма и процент SLA, либо поднимает ошибку о неверном формате даты-времени. diff --git a/app/SLA_calculation.py b/app/SLA_calculation.py new file mode 100644 index 0000000..ad35282 --- /dev/null +++ b/app/SLA_calculation.py @@ -0,0 +1,49 @@ +from datetime import datetime, timedelta +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from .db import sqlalchemy_models as models + + +async def calculate_service_downtime(db: AsyncSession, service_id: int, + start_date: datetime, + end_date: datetime) -> dict[str, Any]: + # Получаем все записи о статусах сервиса за указанный интервал + result = await db.execute( + select(models.ServiceHistory).where( + models.ServiceHistory.service_id == service_id, + models.ServiceHistory.timestamp >= start_date, + models.ServiceHistory.timestamp <= end_date)) + history_records = result.scalars().all() + + total_downtime: timedelta = timedelta() + last_status: str | None = None + last_status_time: datetime | None = None + + # Проходим по всем записям и считаем время простоя + for record in history_records: + if last_status: + if last_status == 'down' and (record.status == 'up' + or record.status == 'unstable'): + # Если был статус 'down' и теперь 'up'/'unstable', добавляем время простоя + downtime_duration: timedelta = record.timestamp - last_status_time + total_downtime += downtime_duration + + # Обновляем последний статус и его время + last_status = record.status + last_status_time = record.timestamp + + # Расчет SLA + total_time: timedelta = end_date - start_date + if total_time.total_seconds() > 0: + sla = (1 - (total_downtime.total_seconds() / + total_time.total_seconds())) * 100 + else: + sla: int | float = 0 + + return { + "total_downtime": str(total_downtime), + "sla_percentage": round(sla, 3) + } diff --git a/app/__pycache__/SLA_calculation.cpython-310.pyc b/app/__pycache__/SLA_calculation.cpython-310.pyc new file mode 100644 index 0000000..dc09833 Binary files /dev/null and b/app/__pycache__/SLA_calculation.cpython-310.pyc differ diff --git a/app/__pycache__/main.cpython-310.pyc b/app/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..7af5ac0 Binary files /dev/null and b/app/__pycache__/main.cpython-310.pyc differ diff --git a/app/db/__pycache__/crud.cpython-310.pyc b/app/db/__pycache__/crud.cpython-310.pyc new file mode 100644 index 0000000..372f085 Binary files /dev/null and b/app/db/__pycache__/crud.cpython-310.pyc differ diff --git a/app/db/__pycache__/database.cpython-310.pyc b/app/db/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000..d8be832 Binary files /dev/null and b/app/db/__pycache__/database.cpython-310.pyc differ diff --git a/app/db/__pycache__/sqlalchemy_models.cpython-310.pyc b/app/db/__pycache__/sqlalchemy_models.cpython-310.pyc new file mode 100644 index 0000000..a399de2 Binary files /dev/null and b/app/db/__pycache__/sqlalchemy_models.cpython-310.pyc differ diff --git a/app/db/crud.py b/app/db/crud.py new file mode 100644 index 0000000..384cdec --- /dev/null +++ b/app/db/crud.py @@ -0,0 +1,57 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from . import sqlalchemy_models as models +from .sqlalchemy_models import Service +from ..models import pydantic_models as schemas + + +async def create_service(db: AsyncSession, + service: schemas.Service) -> models.Service: + service_exist_check = await db.execute( + select(models.Service).where(models.Service.name == service.name)) + existing_service = service_exist_check.scalars().first() + if existing_service: # если сервис с таким названием уже существует, то обновляем его данные + # 1) записываем существующие данные в историю + history_entry = models.ServiceHistory( + service_id=existing_service.id, + status=existing_service.status, + description=existing_service.description) + db.add(history_entry) + + # 2) обновляем актуальные данные о сервисе + existing_service.status = service.status + existing_service.description = service.description + await db.commit() + await db.refresh(existing_service) + return existing_service + else: # иначе добавляем новый сервис в БД + service = models.Service(**service.model_dump()) + db.add(service) + await db.commit() + await db.refresh(service) + return service + + +async def read_services(db: AsyncSession) -> list[models.Service]: ## ## + services = await db.execute(select(models.Service)) + return services.scalars().all() + + +async def read_service_history_by_name( + db: AsyncSession, + service_name: str) -> Optional[list[models.ServiceHistory]]: + service = await db.execute( + select(models.Service).where(models.Service.name == service_name)) + service = service.scalar_one_or_none() + + if not service: + return None + + history_entries = await db.execute( + select(models.ServiceHistory).where( + models.ServiceHistory.service_id == service.id)) + + return history_entries.scalars().all() diff --git a/app/db/database.py b/app/db/database.py new file mode 100644 index 0000000..96a9af6 --- /dev/null +++ b/app/db/database.py @@ -0,0 +1,35 @@ +from sqlalchemy import MetaData +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.ext.declarative import DeclarativeMeta + +from dotenv import load_dotenv +from os import getenv + +from typing import AsyncGenerator + +load_dotenv() +DATABASE_URL: str = getenv("DB_URL") + +engine: AsyncEngine = create_async_engine(DATABASE_URL, echo=True) +metadata: MetaData = MetaData() +Base: DeclarativeMeta = declarative_base() + +async_session = sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def init_db() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session() as session: + await init_db() + yield session diff --git a/app/db/sqlalchemy_models.py b/app/db/sqlalchemy_models.py new file mode 100644 index 0000000..0e7de94 --- /dev/null +++ b/app/db/sqlalchemy_models.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from .database import Base +from datetime import datetime + + +class Service(Base): + __tablename__ = 'services' + + id = Column(Integer, primary_key=True, autoincrement=True) + + name = Column(String, unique=True, nullable=False) + status = Column(String, nullable=False) + description = Column(String, nullable=False) + + +class ServiceHistory(Base): + __tablename__ = 'service_history' + + id = Column(Integer, primary_key=True, index=True) + service_id = Column(Integer, ForeignKey('services.id'), nullable=False) + status = Column(String, nullable=False) + description = Column(String, nullable=False) + timestamp = Column(DateTime, default=datetime.utcnow) + + service = relationship("Service", back_populates="history") + + +Service.history = relationship("ServiceHistory", + order_by=ServiceHistory.id, + back_populates="service") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1c80117 --- /dev/null +++ b/app/main.py @@ -0,0 +1,59 @@ +from fastapi import FastAPI, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from datetime import datetime +from .SLA_calculation import calculate_service_downtime + +from .models.pydantic_models import Service, ServiceHistory +from .db.database import get_db +from .db.crud import create_service, read_services, read_service_history_by_name + +app = FastAPI() + + +@app.post("/services", response_model=dict) +async def post_service( + service: Service, db: AsyncSession = Depends(get_db)) -> dict[str, str]: + new_service = await create_service(db=db, service=service) + if new_service: + return {"message": "service created/updated successfully!"} + return {"message": "service is NOT created/updated successfully!"} # + + +@app.get("/services", response_model=list[Service] | dict[str, str]) +async def get_services(db: AsyncSession = Depends(get_db)) -> list[Service] | dict[str, str]: + services = await read_services(db=db) + if services: + return services + return {"message": "There are no services in the database"} + + +@app.get("/services/{service_name}/history", + response_model=list[ServiceHistory] | dict) +async def get_service_history( + service_name: str, db: AsyncSession = Depends(get_db) +) -> list[ServiceHistory] | dict[str, str]: + history_entries = await read_service_history_by_name(db, service_name) + if not history_entries: + return { + "message": + "Service not found OR Service doesn't have a history yet." + } + return history_entries + + +@app.get("/services/{service_id}/SLA_downtime_info", response_model=dict) +async def get_service_history( + service_id: int, + start_date: datetime, + end_date: datetime, + db: AsyncSession = Depends(get_db)) -> dict: + try: + # Вызываем функцию для расчета времени простоя|SLA + time_data = await calculate_service_downtime(db=db, + service_id=service_id, + start_date=start_date, + end_date=end_date) + return time_data + except ValueError: + raise HTTPException(status_code=400, detail="Invalid datetime format") diff --git a/app/models/__pycache__/pydantic_models.cpython-310.pyc b/app/models/__pycache__/pydantic_models.cpython-310.pyc new file mode 100644 index 0000000..4aa525f Binary files /dev/null and b/app/models/__pycache__/pydantic_models.cpython-310.pyc differ diff --git a/app/models/pydantic_models.py b/app/models/pydantic_models.py new file mode 100644 index 0000000..881254f --- /dev/null +++ b/app/models/pydantic_models.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class Service(BaseModel): + name: str + status: str + description: str + + +class ServiceHistory(BaseModel): + id: int + service_id: int + status: str + description: str diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1247c55 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.7" +services: + database: + image: postgres:15 + environment: + POSTGRES_PASSWORD: mypassword + container_name: db_app + env_file: + - .env-non-dev + + app: + build: + context: . + env_file: + - .env-non-dev + container_name: itpc_app + ports: + - 9990:8000 + depends_on: + - database diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..affad40 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +annotated-types==0.7.0 +anyio==4.4.0 +async-timeout==4.0.3 +asyncpg==0.29.0 +click==8.1.7 +colorama==0.4.6 +exceptiongroup==1.2.2 +fastapi==0.114.1 +greenlet==3.1.0 +h11==0.14.0 +idna==3.8 +importlib_metadata==8.5.0 +platformdirs==4.3.2 +pydantic==2.9.1 +pydantic_core==2.23.3 +python-dotenv==1.0.1 +sniffio==1.3.1 +SQLAlchemy==2.0.34 +starlette==0.38.5 +tomli==2.0.1 +typing_extensions==4.12.2 +uvicorn==0.30.6 +yapf==0.40.2 +zipp==3.20.1 +gunicorn==23.0.0