diff --git a/351001/Budnikov/.python-version b/351001/Budnikov/.python-version new file mode 100644 index 000000000..1019cd9c7 --- /dev/null +++ b/351001/Budnikov/.python-version @@ -0,0 +1 @@ +3.14.3 \ No newline at end of file diff --git a/351001/Budnikov/Dockerfile b/351001/Budnikov/Dockerfile new file mode 100644 index 000000000..ae151c2d0 --- /dev/null +++ b/351001/Budnikov/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.14.3-slim-trixie + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PYTHONPATH /app + +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +COPY src/ ./src/ + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "24110", "--reload"] \ No newline at end of file diff --git a/351001/Budnikov/README.md b/351001/Budnikov/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/docker-compose.yaml b/351001/Budnikov/docker-compose.yaml new file mode 100644 index 000000000..03fc8e524 --- /dev/null +++ b/351001/Budnikov/docker-compose.yaml @@ -0,0 +1,32 @@ +services: + db: + image: postgres:17 + container_name: distcomp_db + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: distcomp + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d distcomp"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + container_name: distcomp_app + restart: always + ports: + - "24110:24110" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DB: distcomp + depends_on: + db: + condition: service_healthy \ No newline at end of file diff --git a/351001/Budnikov/pyproject.toml b/351001/Budnikov/pyproject.toml new file mode 100644 index 000000000..ede785a13 --- /dev/null +++ b/351001/Budnikov/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "budnikov" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14.3" +dependencies = [ + "asyncpg>=0.31.0", + "fastapi>=0.135.3", + "tortoise-orm>=1.1.0", + "uvicorn>=0.44.0", +] + +[tool.ruff] +line-length = 120 + diff --git a/351001/Budnikov/src/__init__.py b/351001/Budnikov/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/api/__init__.py b/351001/Budnikov/src/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/api/v1/__init__.py b/351001/Budnikov/src/api/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/api/v1/endpoints/__init__.py b/351001/Budnikov/src/api/v1/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/api/v1/endpoints/editors.py b/351001/Budnikov/src/api/v1/endpoints/editors.py new file mode 100644 index 000000000..8e5a4405b --- /dev/null +++ b/351001/Budnikov/src/api/v1/endpoints/editors.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, status +from src.schemas.dto import EditorRequestTo, EditorResponseTo +from src.dependencies.services import EditorServiceDep + + +router = APIRouter( + prefix="/editors", +) + + +@router.post( + path="", + response_model=EditorResponseTo, + status_code=status.HTTP_201_CREATED, +) +async def create_editor(editor_in: EditorRequestTo, editor_service: EditorServiceDep): + return await editor_service.create(editor_in) + + +@router.get( + path="", + response_model=list[EditorResponseTo], + status_code=status.HTTP_200_OK, +) +async def get_editors(editor_service: EditorServiceDep): + return await editor_service.get_all() + + +@router.get( + path="/{id}", + response_model=EditorResponseTo, + status_code=status.HTTP_200_OK, +) +async def get_editor(id: int, editor_service: EditorServiceDep): + return await editor_service.get_by_id(id) + + +@router.put( + path="/{id}", + response_model=EditorResponseTo, + status_code=status.HTTP_200_OK, +) +async def update_editor(id: int, editor_in: EditorRequestTo, editor_service: EditorServiceDep): + return await editor_service.update(id, editor_in) + + +@router.delete(path="/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_editor(id: int, editor_service: EditorServiceDep): + await editor_service.delete(id) diff --git a/351001/Budnikov/src/api/v1/endpoints/issues.py b/351001/Budnikov/src/api/v1/endpoints/issues.py new file mode 100644 index 000000000..f5d1817e1 --- /dev/null +++ b/351001/Budnikov/src/api/v1/endpoints/issues.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, status, Query + +from src.schemas.dto import IssueRequestTo, IssueResponseTo, EditorResponseTo, LabelResponseTo, PostResponseTo +from src.dependencies.services import IssueServiceDep + + +router = APIRouter(prefix="/issues") + + +@router.post(path="", response_model=IssueResponseTo, status_code=status.HTTP_201_CREATED) +async def create_issue(issue_in: IssueRequestTo, issue_service: IssueServiceDep): + return await issue_service.create(issue_in) + + +@router.get(path="", response_model=list[IssueResponseTo], status_code=status.HTTP_200_OK) +async def get_issues( + issue_service: IssueServiceDep, + label_names: list[str] | None = Query(None), + label_ids: list[int] | None = Query(None), + editor_login: str | None = Query(None), + title: str | None = Query(None), + content: str | None = Query(None), +): + if any([label_names, label_ids, editor_login, title, content]): + return await issue_service.search_issues(label_names, label_ids, editor_login, title, content) + return await issue_service.get_all() + + +@router.get(path="/{id}", response_model=IssueResponseTo, status_code=status.HTTP_200_OK) +async def get_issue(id: int, issue_service: IssueServiceDep): + return await issue_service.get_by_id(id) + + +@router.put(path="/{id}", response_model=IssueResponseTo, status_code=status.HTTP_200_OK) +async def update_issue(id: int, issue_in: IssueRequestTo, issue_service: IssueServiceDep): + return await issue_service.update(id, issue_in) + + +@router.delete(path="/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_issue(id: int, issue_service: IssueServiceDep): + await issue_service.delete(id) + + +@router.get(path="/{id}/editor", response_model=EditorResponseTo, status_code=status.HTTP_200_OK) +async def get_editor_by_issue(id: int, issue_service: IssueServiceDep): + return await issue_service.get_editor_by_issue(id) + + +@router.get(path="/{id}/labels", response_model=list[LabelResponseTo], status_code=status.HTTP_200_OK) +async def get_labels_by_issue(id: int, issue_service: IssueServiceDep): + return await issue_service.get_labels_by_issue(id) + + +@router.get(path="/{id}/posts", response_model=list[PostResponseTo], status_code=status.HTTP_200_OK) +async def get_posts_by_issue(id: int, issue_service: IssueServiceDep): + return await issue_service.get_posts_by_issue(id) diff --git a/351001/Budnikov/src/api/v1/endpoints/labels.py b/351001/Budnikov/src/api/v1/endpoints/labels.py new file mode 100644 index 000000000..f40c7c7cb --- /dev/null +++ b/351001/Budnikov/src/api/v1/endpoints/labels.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, status +from src.schemas.dto import LabelRequestTo, LabelResponseTo +from src.dependencies.services import LabelServiceDep + + +router = APIRouter(prefix="/labels") + + +@router.post(path="", response_model=LabelResponseTo, status_code=status.HTTP_201_CREATED) +async def create_label(label_in: LabelRequestTo, label_service: LabelServiceDep): + return await label_service.create(label_in) + + +@router.get(path="", response_model=list[LabelResponseTo], status_code=status.HTTP_200_OK) +async def get_labels(label_service: LabelServiceDep): + return await label_service.get_all() + + +@router.get(path="/{id}", response_model=LabelResponseTo, status_code=status.HTTP_200_OK) +async def get_label(id: int, label_service: LabelServiceDep): + return await label_service.get_by_id(id) + + +@router.put(path="/{id}", response_model=LabelResponseTo, status_code=status.HTTP_200_OK) +async def update_label(id: int, label_in: LabelRequestTo, label_service: LabelServiceDep): + return await label_service.update(id, label_in) + + +@router.delete(path="/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_label(id: int, label_service: LabelServiceDep): + await label_service.delete(id) diff --git a/351001/Budnikov/src/api/v1/endpoints/posts.py b/351001/Budnikov/src/api/v1/endpoints/posts.py new file mode 100644 index 000000000..289f3bcbb --- /dev/null +++ b/351001/Budnikov/src/api/v1/endpoints/posts.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, status +from src.schemas.dto import PostRequestTo, PostResponseTo +from src.dependencies.services import PostServiceDep + + +router = APIRouter(prefix="/posts") + + +@router.post(path="", response_model=PostResponseTo, status_code=status.HTTP_201_CREATED) +async def create_post(post_in: PostRequestTo, post_service: PostServiceDep): + return await post_service.create(post_in) + + +@router.get(path="", response_model=list[PostResponseTo], status_code=status.HTTP_200_OK) +async def get_posts(post_service: PostServiceDep): + return await post_service.get_all() + + +@router.get(path="/{id}", response_model=PostResponseTo, status_code=status.HTTP_200_OK) +async def get_post(id: int, post_service: PostServiceDep): + return await post_service.get_by_id(id) + + +@router.put(path="/{id}", response_model=PostResponseTo, status_code=status.HTTP_200_OK) +async def update_post(id: int, post_in: PostRequestTo, post_service: PostServiceDep): + return await post_service.update(id, post_in) + + +@router.delete(path="/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_post(id: int, post_service: PostServiceDep): + await post_service.delete(id) diff --git a/351001/Budnikov/src/api/v1/router.py b/351001/Budnikov/src/api/v1/router.py new file mode 100644 index 000000000..a5d52041d --- /dev/null +++ b/351001/Budnikov/src/api/v1/router.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from src.api.v1.endpoints.editors import router as editor_router +from src.api.v1.endpoints.labels import router as label_router +from src.api.v1.endpoints.posts import router as post_router +from src.api.v1.endpoints.issues import router as issue_router + + +api_router = APIRouter(prefix="/v1.0") + +api_router.include_router(editor_router, tags=["Editors"]) +api_router.include_router(label_router, tags=["Labels"]) +api_router.include_router(post_router, tags=["Posts"]) +api_router.include_router(issue_router, tags=["Issues"]) + + +@api_router.get("/healthcheck") +async def healthcheck(): + return JSONResponse( + status_code=200, + content={"server": "ok"}, + ) diff --git a/351001/Budnikov/src/config/__init__.py b/351001/Budnikov/src/config/__init__.py new file mode 100644 index 000000000..2a5929862 --- /dev/null +++ b/351001/Budnikov/src/config/__init__.py @@ -0,0 +1 @@ +from .database import TORTOISE_CONFIG as TORTOISE_CONFIG diff --git a/351001/Budnikov/src/config/config.py b/351001/Budnikov/src/config/config.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/config/database.py b/351001/Budnikov/src/config/database.py new file mode 100644 index 000000000..629c34ad9 --- /dev/null +++ b/351001/Budnikov/src/config/database.py @@ -0,0 +1,28 @@ +import os + + +DB_USER = os.getenv("POSTGRES_USER", "postgres") +DB_PASSWORD = os.getenv("POSTGRES_PASSWORD", "postgres") +DB_HOST = os.getenv("POSTGRES_HOST", "localhost") +DB_PORT = os.getenv("POSTGRES_PORT", "5432") +DB_NAME = os.getenv("POSTGRES_DB", "distcomp") + +DATABASE_URL = f"postgres://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + +TORTOISE_CONFIG = { + "connections": { + "default": DATABASE_URL, + }, + "apps": { + "models": { + "models": [ + "src.models.editor", + "src.models.post", + "src.models.issue", + "src.models.label", + ], + "default_connection": "default", + } + }, +} diff --git a/351001/Budnikov/src/core/__init__.py b/351001/Budnikov/src/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/core/exceptions.py b/351001/Budnikov/src/core/exceptions.py new file mode 100644 index 000000000..f1c742209 --- /dev/null +++ b/351001/Budnikov/src/core/exceptions.py @@ -0,0 +1,5 @@ +class BaseAppException(Exception): + def __init__(self, status_code: int, error_code: str, error_message: str): + self.status_code = status_code + self.error_code = error_code + self.error_message = error_message diff --git a/351001/Budnikov/src/dependencies/__init__.py b/351001/Budnikov/src/dependencies/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/dependencies/services.py b/351001/Budnikov/src/dependencies/services.py new file mode 100644 index 000000000..8bbf9b796 --- /dev/null +++ b/351001/Budnikov/src/dependencies/services.py @@ -0,0 +1,26 @@ +from typing import Annotated +from fastapi import Depends + +from src.services import EditorService, LabelService, PostService, IssueService + + +def get_editor_service(): + return EditorService() + + +def get_label_service(): + return LabelService() + + +def get_post_service(): + return PostService() + + +def get_issue_service(): + return IssueService() + + +type EditorServiceDep = Annotated[EditorService, Depends(get_editor_service)] +type LabelServiceDep = Annotated[LabelService, Depends(get_label_service)] +type PostServiceDep = Annotated[PostService, Depends(get_post_service)] +type IssueServiceDep = Annotated[IssueService, Depends(get_issue_service)] diff --git a/351001/Budnikov/src/main.py b/351001/Budnikov/src/main.py new file mode 100644 index 000000000..c74a18a02 --- /dev/null +++ b/351001/Budnikov/src/main.py @@ -0,0 +1,73 @@ +import sys +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from tortoise.contrib.fastapi import register_tortoise + +from src.api.v1.router import api_router +from src.core.exceptions import BaseAppException +from src.config import TORTOISE_CONFIG + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # await AsyncORM.create_tables() # Uncomment to use instead of migrations + + yield + + # actions after + + +def create_fastapi_app(): + app = FastAPI(lifespan=lifespan, redirect_slashes=False) + + app.include_router(api_router, prefix="/api") + + register_tortoise( + app, + config=TORTOISE_CONFIG, + generate_schemas=True, + ) + + return app + + +def init_logger(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("app.log"), logging.StreamHandler(sys.stdout)], + ) + logger = logging.getLogger(__name__) + logger.info("Logger is initialized") + + +init_logger() + +app = create_fastapi_app() + + +@app.exception_handler(BaseAppException) +async def app_exception_handler(request: Request, exc: BaseAppException): + return JSONResponse( + status_code=exc.status_code, + content={ + "errorCode": exc.error_code, + "errorMessage": exc.error_message, + }, + ) + + +# @app.exception_handler(IntegrityError) +# async def integrity_error_handler(request: Request, exc: IntegrityError): +# logging.error(f"Integrity Error: {str(exc)}") +# +# return JSONResponse( +# status_code=403, +# content={ +# "errorCode": "40301", +# "errorMessage": f"Resource already exists: {str(exc)}", +# }, +# ) diff --git a/351001/Budnikov/src/models/__init__.py b/351001/Budnikov/src/models/__init__.py new file mode 100644 index 000000000..fb492c8bf --- /dev/null +++ b/351001/Budnikov/src/models/__init__.py @@ -0,0 +1,4 @@ +from .editor import Editor as Editor +from .label import Label as Label +from .issue import Issue as Issue +from .post import Post as Post diff --git a/351001/Budnikov/src/models/editor.py b/351001/Budnikov/src/models/editor.py new file mode 100644 index 000000000..60533ec76 --- /dev/null +++ b/351001/Budnikov/src/models/editor.py @@ -0,0 +1,14 @@ +from tortoise import fields, models + + +class Editor(models.Model): + id = fields.IntField(pk=True) + login = fields.CharField(max_length=64, unique=True) + password = fields.CharField(max_length=128) + firstname = fields.CharField(max_length=64) + lastname = fields.CharField(max_length=64) + + issues: fields.ReverseRelation["Issue"] + + class Meta: + table = "tbl_editor" diff --git a/351001/Budnikov/src/models/issue.py b/351001/Budnikov/src/models/issue.py new file mode 100644 index 000000000..215ed398f --- /dev/null +++ b/351001/Budnikov/src/models/issue.py @@ -0,0 +1,16 @@ +from tortoise import fields, models + + +class Issue(models.Model): + id = fields.IntField(pk=True) + editor = fields.ForeignKeyField("models.Editor", related_name="issues") + title = fields.CharField(max_length=64, unique=True) + content = fields.CharField(max_length=2048) + created = fields.DatetimeField(auto_now_add=True) + modified = fields.DatetimeField(auto_now=True) + labels = fields.ManyToManyField("models.Label", related_name="issues", through="m2m_issues_labels") + + posts: fields.ReverseRelation["Post"] + + class Meta: + table = "tbl_issue" diff --git a/351001/Budnikov/src/models/label.py b/351001/Budnikov/src/models/label.py new file mode 100644 index 000000000..42cd825b1 --- /dev/null +++ b/351001/Budnikov/src/models/label.py @@ -0,0 +1,11 @@ +from tortoise import fields, models + + +class Label(models.Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=32, unique=True) + + issues: fields.ManyToManyRelation["Issue"] + + class Meta: + table = "tbl_label" diff --git a/351001/Budnikov/src/models/post.py b/351001/Budnikov/src/models/post.py new file mode 100644 index 000000000..ff9b23ae2 --- /dev/null +++ b/351001/Budnikov/src/models/post.py @@ -0,0 +1,10 @@ +from tortoise import fields, models + + +class Post(models.Model): + id = fields.IntField(pk=True) + content = fields.TextField() + issue = fields.ForeignKeyField("models.Issue", related_name="posts") + + class Meta(): + table = "tbl_post" diff --git a/351001/Budnikov/src/schemas/__init__.py b/351001/Budnikov/src/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/351001/Budnikov/src/schemas/dto.py b/351001/Budnikov/src/schemas/dto.py new file mode 100644 index 000000000..e8b8c07c6 --- /dev/null +++ b/351001/Budnikov/src/schemas/dto.py @@ -0,0 +1,57 @@ +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field + + +class EditorRequestTo(BaseModel): + login: str = Field(min_length=2, max_length=64) + password: str = Field(min_length=8, max_length=128) + firstname: str = Field(min_length=2, max_length=64) + lastname: str = Field(min_length=2, max_length=64) + + +class EditorResponseTo(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + login: str + firstname: str + lastname: str + + +class LabelRequestTo(BaseModel): + name: str = Field(min_length=2, max_length=32) + + +class LabelResponseTo(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + + +class PostRequestTo(BaseModel): + content: str = Field(min_length=2, max_length=2048) + issue_id: int = Field(alias="issueId") + + +class PostResponseTo(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + id: int + content: str + issue_id: int = Field(serialization_alias="issueId") + + +class IssueRequestTo(BaseModel): + title: str = Field(min_length=2, max_length=64) + content: str = Field(min_length=2, max_length=2048) + editor_id: int = Field(alias="editorId") + label_ids: list[int] = Field(default_factory=list, alias="labelIds") + labels: list[str] = Field(default_factory=list) + + +class IssueResponseTo(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + id: int + title: str + content: str + created: datetime + modified: datetime + editor_id: int = Field(serialization_alias="editorId") \ No newline at end of file diff --git a/351001/Budnikov/src/services/__init__.py b/351001/Budnikov/src/services/__init__.py new file mode 100644 index 000000000..98049d0a6 --- /dev/null +++ b/351001/Budnikov/src/services/__init__.py @@ -0,0 +1,4 @@ +from .editor import EditorService as EditorService +from .label import LabelService as LabelService +from .post import PostService as PostService +from .issue import IssueService as IssueService diff --git a/351001/Budnikov/src/services/base.py b/351001/Budnikov/src/services/base.py new file mode 100644 index 000000000..aed956ecf --- /dev/null +++ b/351001/Budnikov/src/services/base.py @@ -0,0 +1,55 @@ +from typing import TypeVar, Generic, Type, List + +from tortoise.models import Model +from tortoise.exceptions import IntegrityError +from pydantic import BaseModel + +from src.core.exceptions import BaseAppException + + +ModelType = TypeVar("ModelType", bound=Model) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) +ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel) + + +class BaseCRUDService(Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]): + def __init__(self, model: Type[ModelType], response_schema: Type[ResponseSchemaType]): + self.model = model + self.response_schema = response_schema + + async def get_all(self) -> List[ResponseSchemaType]: + objs = await self.model.all() + return [self.response_schema.model_validate(obj) for obj in objs] + + async def get_by_id(self, obj_id: int) -> ResponseSchemaType: + obj = await self.model.get_or_none(id=obj_id) + if not obj: + raise BaseAppException(404, "40401", f"{self.model.__name__} with id {obj_id} not found") + return self.response_schema.model_validate(obj) + + async def create(self, create_dto: CreateSchemaType) -> ResponseSchemaType: + try: + data = create_dto.model_dump(exclude_unset=True) + obj = await self.model.create(**data) + await obj.refresh_from_db() + except IntegrityError as e: + raise BaseAppException(403, "40301", f"Validation Error: {str(e)}") + return self.response_schema.model_validate(obj) + + async def update(self, obj_id: int, update_dto: UpdateSchemaType) -> ResponseSchemaType: + obj = await self.model.get_or_none(id=obj_id) + if not obj: + raise BaseAppException(404, "40402", f"{self.model.__name__} not found") + try: + data = update_dto.model_dump(exclude_unset=True) + await obj.update_from_dict(data).save() + await obj.refresh_from_db() + except IntegrityError as e: + raise BaseAppException(403, "40301", f"Validation Error: {str(e)}") + return self.response_schema.model_validate(obj) + + async def delete(self, obj_id: int) -> None: + deleted_count = await self.model.filter(id=obj_id).delete() + if not deleted_count: + raise BaseAppException(404, "40403", f"{self.model.__name__} not found") \ No newline at end of file diff --git a/351001/Budnikov/src/services/editor.py b/351001/Budnikov/src/services/editor.py new file mode 100644 index 000000000..00e5d9aeb --- /dev/null +++ b/351001/Budnikov/src/services/editor.py @@ -0,0 +1,8 @@ +from src.services.base import BaseCRUDService +from src.models import Editor +from src.schemas.dto import EditorRequestTo, EditorResponseTo + + +class EditorService(BaseCRUDService[Editor, EditorRequestTo, EditorRequestTo, EditorResponseTo]): + def __init__(self): + super().__init__(Editor, EditorResponseTo) diff --git a/351001/Budnikov/src/services/issue.py b/351001/Budnikov/src/services/issue.py new file mode 100644 index 000000000..3dab9232a --- /dev/null +++ b/351001/Budnikov/src/services/issue.py @@ -0,0 +1,162 @@ +from tortoise.exceptions import IntegrityError +from tortoise.transactions import in_transaction + +from src.services.base import BaseCRUDService +from src.models import Issue, Editor, Label +from src.schemas.dto import IssueRequestTo, IssueResponseTo, EditorResponseTo, LabelResponseTo, PostResponseTo +from src.core.exceptions import BaseAppException + + +class IssueService(BaseCRUDService[Issue, IssueRequestTo, IssueRequestTo, IssueResponseTo]): + def __init__(self): + super().__init__(Issue, IssueResponseTo) + + async def _cleanup_labels(self, labels_list: list[Label]): + for label in labels_list: + count = await label.issues.all().count() + if count == 0: + await label.delete() + + async def create(self, create_dto: IssueRequestTo) -> IssueResponseTo: + editor = await Editor.get_or_none(id=create_dto.editor_id) + if not editor: + raise BaseAppException(400, "40003", f"Editor with id {create_dto.editor_id} not found") + + async with in_transaction(): + try: + issue = await self.model.create( + title=create_dto.title, + content=create_dto.content, + editor_id=create_dto.editor_id + ) + except IntegrityError as e: + raise BaseAppException(403, "40301", f"Validation Error: {str(e)}") + + if create_dto.label_ids: + labels_by_id = await Label.filter(id__in=create_dto.label_ids) + if len(labels_by_id) != len(create_dto.label_ids): + raise BaseAppException(400, "40005", "One or more labels not found") + await issue.labels.add(*labels_by_id) + + if create_dto.labels: + for label_name in create_dto.labels: + label_obj, _ = await Label.get_or_create(name=label_name) + await issue.labels.add(label_obj) + + return self.response_schema.model_validate(issue) + + async def update(self, obj_id: int, update_dto: IssueRequestTo) -> IssueResponseTo: + issue = await self.model.get_or_none(id=obj_id).prefetch_related("labels") + if not issue: + raise BaseAppException(404, "40403", "Issue not found") + + editor = await Editor.get_or_none(id=update_dto.editor_id) + if not editor: + raise BaseAppException(400, "40003", "Editor not found") + + async with in_transaction(): + # Запоминаем текущие метки для последующей очистки + old_labels = list(issue.labels) + + issue.title = update_dto.title + issue.content = update_dto.content + issue.editor_id = update_dto.editor_id + + try: + await issue.save() + except IntegrityError as e: + raise BaseAppException(403, "40301", f"Validation Error: {str(e)}") + + await issue.labels.clear() + + if update_dto.label_ids: + labels_by_id = await Label.filter(id__in=update_dto.label_ids) + await issue.labels.add(*labels_by_id) + + if update_dto.labels: + for label_name in update_dto.labels: + label_obj, _ = await Label.get_or_create(name=label_name) + await issue.labels.add(label_obj) + + await self._cleanup_labels(old_labels) + + return self.response_schema.model_validate(issue) + + async def delete(self, obj_id: int) -> None: + issue = await self.model.get_or_none(id=obj_id).prefetch_related("labels") + if not issue: + raise BaseAppException(404, "40403", "Issue not found") + + labels_to_check = list(issue.labels) + + async with in_transaction(): + await issue.delete() + await self._cleanup_labels(labels_to_check) + + async def get_editor_by_issue(self, issue_id: int) -> EditorResponseTo: + issue = await self.model.get_or_none(id=issue_id).prefetch_related("editor") + if not issue: raise BaseAppException(404, "40403", "Issue not found") + return EditorResponseTo.model_validate(issue.editor) + + async def get_labels_by_issue(self, issue_id: int) -> list[LabelResponseTo]: + issue = await self.model.get_or_none(id=issue_id).prefetch_related("labels") + if not issue: raise BaseAppException(404, "40403", "Issue not found") + return [LabelResponseTo.model_validate(label) for label in issue.labels] + + async def get_posts_by_issue(self, issue_id: int) -> list[PostResponseTo]: + issue = await self.model.get_or_none(id=issue_id).prefetch_related("posts") + if not issue: raise BaseAppException(404, "40403", "Issue not found") + return [PostResponseTo.model_validate(post) for post in issue.posts] + + async def search_issues(self, label_names=None, label_ids=None, editor_login=None, title=None, content=None): + query = self.model.all() + if label_names: query = query.filter(labels__name__in=label_names) + if label_ids: query = query.filter(labels__id__in=label_ids) + if editor_login: query = query.filter(editor__login=editor_login) + if title: query = query.filter(title__icontains=title) + if content: query = query.filter(content__icontains=content) + issues = await query.distinct() + return [self.response_schema.model_validate(issue) for issue in issues] + + async def get_editor_by_issue(self, issue_id: int) -> EditorResponseTo: + issue = await self.model.get_or_none(id=issue_id).prefetch_related("editor") + if not issue: + raise BaseAppException(404, "40403", "Issue not found") + return EditorResponseTo.model_validate(issue.editor) + + async def get_labels_by_issue(self, issue_id: int) -> list[LabelResponseTo]: + issue = await self.model.get_or_none(id=issue_id).prefetch_related("labels") + if not issue: + raise BaseAppException(404, "40403", "Issue not found") + return [LabelResponseTo.model_validate(label) for label in issue.labels] + + async def get_posts_by_issue(self, issue_id: int) -> list[PostResponseTo]: + issue = await self.model.get_or_none(id=issue_id).prefetch_related("posts") + if not issue: + raise BaseAppException(404, "40403", "Issue not found") + return [PostResponseTo.model_validate(post) for post in issue.posts] + + async def search_issues( + self, + label_names: list[str] | None = None, + label_ids: list[int] | None = None, + editor_login: str | None = None, + title: str | None = None, + content: str | None = None, + ) -> list[IssueResponseTo]: + + query = self.model.all() + + if label_names: + query = query.filter(labels__name__in=label_names) + if label_ids: + query = query.filter(labels__id__in=label_ids) + if editor_login: + query = query.filter(editor__login=editor_login) + if title: + query = query.filter(title__icontains=title) + if content: + query = query.filter(content__icontains=content) + + issues = await query.distinct() + return [self.response_schema.model_validate(issue) for issue in issues] diff --git a/351001/Budnikov/src/services/label.py b/351001/Budnikov/src/services/label.py new file mode 100644 index 000000000..19a48102d --- /dev/null +++ b/351001/Budnikov/src/services/label.py @@ -0,0 +1,8 @@ +from src.services.base import BaseCRUDService +from src.models import Label +from src.schemas.dto import LabelRequestTo, LabelResponseTo + + +class LabelService(BaseCRUDService[Label, LabelRequestTo, LabelRequestTo, LabelResponseTo]): + def __init__(self): + super().__init__(Label, LabelResponseTo) diff --git a/351001/Budnikov/src/services/post.py b/351001/Budnikov/src/services/post.py new file mode 100644 index 000000000..6d8f65042 --- /dev/null +++ b/351001/Budnikov/src/services/post.py @@ -0,0 +1,16 @@ +from src.services.base import BaseCRUDService +from src.models import Post, Issue +from src.schemas.dto import PostRequestTo, PostResponseTo +from src.core.exceptions import BaseAppException + + +class PostService(BaseCRUDService[Post, PostRequestTo, PostRequestTo, PostResponseTo]): + def __init__(self): + super().__init__(Post, PostResponseTo) + + async def create(self, create_dto: PostRequestTo) -> PostResponseTo: + issue_exists = await Issue.filter(id=create_dto.issue_id).exists() + if not issue_exists: + raise BaseAppException(400, "40004", f"Issue with id {create_dto.issue_id} not found") + + return await super().create(create_dto) diff --git a/351001/Budnikov/uv.lock b/351001/Budnikov/uv.lock new file mode 100644 index 000000000..f8f6beb4a --- /dev/null +++ b/351001/Budnikov/uv.lock @@ -0,0 +1,273 @@ +version = 1 +revision = 3 +requires-python = ">=3.14.3" + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "budnikov" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "tortoise-orm" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "fastapi", specifier = ">=0.135.3" }, + { name = "tortoise-orm", specifier = ">=1.1.0" }, + { name = "uvicorn", specifier = ">=0.44.0" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pypika-tortoise" +version = "0.6.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/06/89fe5fff93c5a01dbdeb9f3d843a7e997dc6e3a87222a260a164ff91fb81/pypika_tortoise-0.6.5.tar.gz", hash = "sha256:64d96c9b88450f6360ad22a7063933b6a90961a7317f04b2b63c98fd5d705506", size = 81468, upload-time = "2026-03-13T20:44:54.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tortoise-orm" +version = "1.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "anyio" }, + { name = "iso8601", marker = "python_full_version < '4'" }, + { name = "pypika-tortoise" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/55/e75d3ae0dd2c96cf961bf068f465fb62ec481d802beb65f406620bfd40a0/tortoise_orm-1.1.7.tar.gz", hash = "sha256:a0f0f27f9c92547739f0a3f0862c257ab64d8baadbc0e548cbcfa2d70bcbe275", size = 387489, upload-time = "2026-03-21T20:03:29.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9b/e7e5b4f4e9433b9d9c389afc62ea0ed2597626842dc0f76dd7b54eb32cdd/tortoise_orm-1.1.7-py3-none-any.whl", hash = "sha256:b640b786905891cc120aa16d1a7af18cd54b111a812f9b1c1d1c7b294d62949d", size = 272437, upload-time = "2026-03-21T20:03:27.539Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +]