diff --git a/README.md b/README.md index cfb476f..30f9878 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,27 @@ -# Вакансия :: Программист 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С, то эта вакансия не для вас. - -## Тестовое задание - -Решение принимается в виде PR к текущему проекту. - -Есть несколько рабочих сервисов, у каждого сервиса есть состояние работает/не работает/работает нестабильно. - -Требуется написать API который: - -1. Получает и сохраняет данные: имя, состояние, описание -2. Выводит список сервисов с актуальным состоянием -3. По имени сервиса выдает историю изменения состояния и все данные по каждому состоянию - -Дополнительным плюсом будет - -1. По указанному интервалу выдается информация о том сколько не работал сервис и считать SLA в процентах до 3-й запятой - -Вывод всех данных должен быть в формате JSON +# Python TEST TASK for itpc.ru +Этот проект представляет собой API, которое позволяет выполнять следующие действия: + + - Сохранение данных: API принимает данные, включая имя сервиса, его текущее состояние и описание, и сохраняет их в базе данных. + - Вывод списка сервисов с актуальным состоянием: API предоставляет эндпоинт для получения списка сервисов с их текущим состоянием. + - История изменения состояния: По имени сервиса API позволяет получать историю изменения состояния и всю доступную информацию по каждому состоянию сервиса. + +## Ресурсы +- [FastAPI](https://fastapi.tiangolo.com/) +- [SQLalchemy](https://www.sqlalchemy.org/) +- [Pydantic](https://docs.pydantic.dev/latest/) +- [PostgreSQL](https://www.postgresql.org/) + + +## Документация +Установка зависимостей: +``` +pip install -r reuirements.txt +``` +Запуск через main.py, либо через консоль: +``` +uvicorn main:app --reload +``` + + +После запуска документация доступна по адресу http://127.0.0.1:8000/docs/ +Реализовано через OpenAPI(Swagger) diff --git a/__pycache__/database.cpython-310.pyc b/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000..776094f Binary files /dev/null and b/__pycache__/database.cpython-310.pyc differ diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..b890f60 Binary files /dev/null and b/__pycache__/main.cpython-310.pyc differ diff --git a/__pycache__/settings.cpython-310.pyc b/__pycache__/settings.cpython-310.pyc new file mode 100644 index 0000000..2e7f589 Binary files /dev/null and b/__pycache__/settings.cpython-310.pyc differ diff --git a/database.py b/database.py new file mode 100644 index 0000000..8abb78f --- /dev/null +++ b/database.py @@ -0,0 +1,58 @@ +from sqlalchemy.exc import OperationalError, ResourceClosedError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine +from schemas.base import Base +from settings import settings +import asyncpg +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +# SQLALCHEMY_URL = settings.SQLALCHEMY_URL + +SQLALCHEMY_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres10" +engine = create_async_engine(SQLALCHEMY_URL, echo=False, pool_pre_ping=True) +async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + + +class get_db: + def __init__(self): + pass + + async def create_tables(self) -> None: + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + except OperationalError as error: + pass + + async def drop_tables(self) -> None: + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + except OperationalError as error: + pass + + async def insert(self, stmt: Base) -> None: + try: + async with async_session() as session: + async with session.begin(): + session.add(stmt) + await session.commit() + except OperationalError as error: + pass + + async def execute(self, stmt: Base) -> list | None: + try: + async with async_session() as session: + async with session.begin(): + result = await session.execute(stmt) + try: + result = [u._asdict() for u in result.all()] + except ResourceClosedError: + return None + return result if len(result) > 0 else None + + except OperationalError as error: + pass diff --git a/main.py b/main.py new file mode 100644 index 0000000..e6c5328 --- /dev/null +++ b/main.py @@ -0,0 +1,34 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +import routers +from database import get_db +from settings import settings + + +app = FastAPI() + + +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + +app.include_router(routers.router) + + +@app.on_event('startup') +async def startup_event(): + database = get_db() + if settings.DROP_DATABASE: + await database.drop_tables() + await database.create_tables() + + +if __name__ == '__main__': + + uvicorn.run("main:app", host='127.0.0.1', port=8000, reload=True) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/__pycache__/__init__.cpython-310.pyc b/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..eccc87e Binary files /dev/null and b/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/models/__pycache__/activity.cpython-310.pyc b/models/__pycache__/activity.cpython-310.pyc new file mode 100644 index 0000000..8922050 Binary files /dev/null and b/models/__pycache__/activity.cpython-310.pyc differ diff --git a/models/activity.py b/models/activity.py new file mode 100644 index 0000000..54db732 --- /dev/null +++ b/models/activity.py @@ -0,0 +1,27 @@ +from datetime import datetime +from enum import Enum +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + + +class ServiceStatus(str, Enum): + ACTIVE = 'ACTIVE' + UNSTABLE = 'UNSTABLE' + STOPPED = 'STOPPED' + + + +class CurrentActivity(BaseModel): + service_name: str = Field(example='Service_name') + status: ServiceStatus = Field(default=ServiceStatus.ACTIVE) + description: str = Field(example='Server is Active ') + + +class ServicesList(CurrentActivity): + status_map: dict + + +class ServicesLog(CurrentActivity): + log_uuid: UUID = Field(default_factory=uuid4) + created_date: datetime diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..30f9878 --- /dev/null +++ b/readme.md @@ -0,0 +1,27 @@ +# Python TEST TASK for itpc.ru +Этот проект представляет собой API, которое позволяет выполнять следующие действия: + + - Сохранение данных: API принимает данные, включая имя сервиса, его текущее состояние и описание, и сохраняет их в базе данных. + - Вывод списка сервисов с актуальным состоянием: API предоставляет эндпоинт для получения списка сервисов с их текущим состоянием. + - История изменения состояния: По имени сервиса API позволяет получать историю изменения состояния и всю доступную информацию по каждому состоянию сервиса. + +## Ресурсы +- [FastAPI](https://fastapi.tiangolo.com/) +- [SQLalchemy](https://www.sqlalchemy.org/) +- [Pydantic](https://docs.pydantic.dev/latest/) +- [PostgreSQL](https://www.postgresql.org/) + + +## Документация +Установка зависимостей: +``` +pip install -r reuirements.txt +``` +Запуск через main.py, либо через консоль: +``` +uvicorn main:app --reload +``` + + +После запуска документация доступна по адресу http://127.0.0.1:8000/docs/ +Реализовано через OpenAPI(Swagger) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c8f0d5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +annotated-types==0.6.0 +anyio==3.7.1 +asyncpg==0.28.0 +certifi==2023.7.22 +click==8.1.7 +colorama==0.4.6 +dnspython==2.4.2 +email-validator==2.1.0.post1 +exceptiongroup==1.1.3 +fastapi==0.104.0 +greenlet==3.0.1 +h11==0.14.0 +httpcore==0.18.0 +httptools==0.6.1 +httpx==0.25.0 +idna==3.4 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +orjson==3.9.10 +pydantic==2.4.2 +pydantic-extra-types==2.1.0 +pydantic-settings==2.0.3 +pydantic_core==2.10.1 +python-dotenv==1.0.0 +python-multipart==0.0.6 +PyYAML==6.0.1 +sniffio==1.3.0 +SQLAlchemy==2.0.22 +starlette==0.27.0 +typing_extensions==4.8.0 +ujson==5.8.0 +uvicorn==0.23.2 +watchfiles==0.21.0 +websockets==12.0 diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..cea3fce --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from routers import log_router + +router = APIRouter() + +router.include_router(log_router.router) diff --git a/routers/__pycache__/__init__.cpython-310.pyc b/routers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..e9b785e Binary files /dev/null and b/routers/__pycache__/__init__.cpython-310.pyc differ diff --git a/routers/__pycache__/log_router.cpython-310.pyc b/routers/__pycache__/log_router.cpython-310.pyc new file mode 100644 index 0000000..2dcc263 Binary files /dev/null and b/routers/__pycache__/log_router.cpython-310.pyc differ diff --git a/routers/log_router.py b/routers/log_router.py new file mode 100644 index 0000000..c79ba7b --- /dev/null +++ b/routers/log_router.py @@ -0,0 +1,33 @@ +from typing import List +from fastapi import APIRouter, Depends + +from models.activity import CurrentActivity, ServicesList, ServicesLog +from services.log_service import LogService +from services.service_service import ServicesService + +router = APIRouter(prefix='/logs', tags=['Logs']) + +"""Фиксируем активность сервера""" +@router.post('/save_logs/') +async def log_activity( + data: CurrentActivity, + activity_service: LogService = Depends(), +): + + return await activity_service.log_activity(data) + +"""Список всех серверов""" +@router.get('/list_services/', response_model=List[ServicesList] | None) +async def list_services( + service_service: ServicesService = Depends(), +): + + return await service_service.list_services() + +"""Логи сервера""" +@router.get('/list_logs/', response_model=List[ServicesLog]) +async def list_logs( + service_name: str, + activity_service: LogService = Depends(), +): + return await activity_service.list_logs(service_name) diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..53fe1cf --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,2 @@ +from schemas.services_logs_schemas import ServiceLogDB +from schemas.services_schemas import ServiceDB diff --git a/schemas/__pycache__/__init__.cpython-310.pyc b/schemas/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..6f00e34 Binary files /dev/null and b/schemas/__pycache__/__init__.cpython-310.pyc differ diff --git a/schemas/__pycache__/base.cpython-310.pyc b/schemas/__pycache__/base.cpython-310.pyc new file mode 100644 index 0000000..0f53e29 Binary files /dev/null and b/schemas/__pycache__/base.cpython-310.pyc differ diff --git a/schemas/__pycache__/services_logs_schemas.cpython-310.pyc b/schemas/__pycache__/services_logs_schemas.cpython-310.pyc new file mode 100644 index 0000000..0a0e20b Binary files /dev/null and b/schemas/__pycache__/services_logs_schemas.cpython-310.pyc differ diff --git a/schemas/__pycache__/services_schemas.cpython-310.pyc b/schemas/__pycache__/services_schemas.cpython-310.pyc new file mode 100644 index 0000000..123db78 Binary files /dev/null and b/schemas/__pycache__/services_schemas.cpython-310.pyc differ diff --git a/schemas/base.py b/schemas/base.py new file mode 100644 index 0000000..b91e6ed --- /dev/null +++ b/schemas/base.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import BIGINT, TIMESTAMP, String, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.sql.schema import Column + + +class Base(DeclarativeBase): + type_annotation_map = { + int: BIGINT, + datetime: TIMESTAMP(timezone=False), + str: String(), + } + + created_date: Mapped[datetime] = mapped_column(server_default=func.now(), index=True) + update_date: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + +def to_sql(pidantic_schemas): + """Convert Pydantic schemas to column names""" + keys = pidantic_schemas.__fields__.keys() + keys = [Column(key) for key in keys] + return keys diff --git a/schemas/services_logs_schemas.py b/schemas/services_logs_schemas.py new file mode 100644 index 0000000..fc09588 --- /dev/null +++ b/schemas/services_logs_schemas.py @@ -0,0 +1,15 @@ +from uuid import uuid4 + +from sqlalchemy import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql.schema import ForeignKey + +from schemas.base import Base + + +class ServiceLogDB(Base): + __tablename__ = 'services_logs' + log_uuid: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4) + service_name = mapped_column(ForeignKey('services.service_name'), nullable=True) + status: Mapped[str] = mapped_column(nullable=False) + description: Mapped[str] = mapped_column(nullable=False, default='') diff --git a/schemas/services_schemas.py b/schemas/services_schemas.py new file mode 100644 index 0000000..b4f50bd --- /dev/null +++ b/schemas/services_schemas.py @@ -0,0 +1,12 @@ +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from schemas.base import Base + + +class ServiceDB(Base): + __tablename__ = 'services' + service_name: Mapped[str] = mapped_column(primary_key=True) + status: Mapped[str] = mapped_column(index=True) + description: Mapped[str] = mapped_column(nullable=True) + status_map = mapped_column(JSONB, default={}) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/__pycache__/__init__.cpython-310.pyc b/services/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b8d2512 Binary files /dev/null and b/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/services/__pycache__/log_service.cpython-310.pyc b/services/__pycache__/log_service.cpython-310.pyc new file mode 100644 index 0000000..a02a2e8 Binary files /dev/null and b/services/__pycache__/log_service.cpython-310.pyc differ diff --git a/services/__pycache__/service_service.cpython-310.pyc b/services/__pycache__/service_service.cpython-310.pyc new file mode 100644 index 0000000..7da9b53 Binary files /dev/null and b/services/__pycache__/service_service.cpython-310.pyc differ diff --git a/services/log_service.py b/services/log_service.py new file mode 100644 index 0000000..96fda10 --- /dev/null +++ b/services/log_service.py @@ -0,0 +1,60 @@ +from typing import NoReturn + +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert + +from database import get_db +from models.activity import CurrentActivity, ServicesList, ServicesLog +from schemas.base import to_sql +from schemas.services_logs_schemas import ServiceLogDB +from schemas.services_schemas import ServiceDB +from services.service_service import ServicesService + + +class LogService: + def __init__(self): + self.session = get_db() + self.service_service = ServicesService() + + async def get_last_log(self, service_name: str): + last_log = await self.session.execute( + select(ServiceLogDB) + .where(ServiceLogDB.service_name == service_name) + .order_by(ServiceLogDB.created_date.desc()) + .limit(1) + ) + return last_log[0]['ServiceLogDB'] if last_log else None + + async def list_logs(self, service_name: str) -> list: + service_name = service_name.lower() # ToDO : проверки нужны по шаблонам и тд, тк дыра в безопасности + result = await self.session.execute( + select(*to_sql(ServicesLog)).select_from(ServiceLogDB).where(ServiceLogDB.service_name == service_name) + ) + return [ServicesLog(**_) for _ in result] if result else [] + + async def log_activity(self, data: CurrentActivity) -> NoReturn: + + data.service_name = data.service_name.lower() # ToDO : проверки нужны по шаблонам и тд, тк дыра в безопасности + + service_data = await self.service_service.get_service_data(data) + last_log = await self.get_last_log(data.service_name) + + current_log_date = await self.session.execute( + insert(ServiceLogDB).values(**data.__dict__).returning(ServiceLogDB.created_date) + ) + current_log_created_date = current_log_date[0]['created_date'] + status_map = service_data.status_map + all_time = status_map.get('time_all', 0) + + delta_time = 0 + if last_log: + delta_time = (current_log_created_date - last_log.created_date).total_seconds() + if not status_map.get(last_log.status): + status_map[last_log.status] = 0 + status_map[last_log.status] += delta_time + + status_map['all_time'] = all_time + delta_time + + await self.session.execute( + update(ServiceDB).values({'status_map': status_map}).where(ServiceDB.service_name == data.service_name) + ) \ No newline at end of file diff --git a/services/service_service.py b/services/service_service.py new file mode 100644 index 0000000..a529f1e --- /dev/null +++ b/services/service_service.py @@ -0,0 +1,24 @@ +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.future import select + +from database import get_db +from models.activity import CurrentActivity, ServicesList +from schemas.base import to_sql +from schemas.services_schemas import ServiceDB + + +class ServicesService: + def __init__(self): + self.session = get_db() + + async def get_service_data(self, data: CurrentActivity) -> ServiceDB: + service_data = await self.session.execute(select(ServiceDB).where(ServiceDB.service_name == data.service_name)) + if service_data: + return service_data[0]['ServiceDB'] + + service_data = await self.session.execute(insert(ServiceDB).values(**data.__dict__).returning(ServiceDB)) + return service_data[0]['ServiceDB'] + + async def list_services(self): + result = await self.session.execute(select(*to_sql(ServicesList)).select_from(ServiceDB)) + return result diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..e0547cd --- /dev/null +++ b/settings.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # SQLALCHEMY_URL: str = 'postgresql+asyncpg://postgres:postgres@localhost:5432/postgres 2' + DROP_DATABASE: bool = False + + DB_HOST: str = 'localhost' + DB_PORT: str = '5432' + DB_NAME: str = 'postgres 2' + DB_USER: str = 'postgres' + DB_PASS: str = 'postgres' + +settings = Settings( + _env_file='../.env', + _env_file_encoding='utf-8', +)