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/) в качестве стандартного пакетного менеджера. 
+ - [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
+
+
+## Кратко про существующие эндпоинты:
+ - 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