From 32c34e78f7831580f0fc30f86631ba47ffe49327 Mon Sep 17 00:00:00 2001 From: aurinex <152972480+aurinex@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:08:07 +0500 Subject: [PATCH] init --- .gitignore | 3 ++ Dockerfile | 19 +++++++++ app/api/v1/__init__.py | 5 +++ app/api/v1/routes/files.py | 73 ++++++++++++++++++++++++++++++++++ app/core/config.py | 22 ++++++++++ app/db/mongo.py | 25 ++++++++++++ app/main.py | 23 +++++++++++ app/models/file_meta.py | 11 +++++ app/repositories/files_repo.py | 22 ++++++++++ app/schemas/file.py | 16 ++++++++ app/services/files_service.py | 55 +++++++++++++++++++++++++ app/services/storage.py | 23 +++++++++++ docker-compose.yml | 25 ++++++++++++ requirements.txt | 7 ++++ 14 files changed, 329 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/routes/files.py create mode 100644 app/core/config.py create mode 100644 app/db/mongo.py create mode 100644 app/main.py create mode 100644 app/models/file_meta.py create mode 100644 app/repositories/files_repo.py create mode 100644 app/schemas/file.py create mode 100644 app/services/files_service.py create mode 100644 app/services/storage.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04cf940 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/venv/ +/.env +/.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..121e7ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN pip install --no-cache-dir -U pip + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +# папка для файлов +RUN mkdir -p /data/uploads + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3002"] diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..9180de2 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from app.api.v1.routes.files import router as files_router + +api_router = APIRouter() +api_router.include_router(files_router) diff --git a/app/api/v1/routes/files.py b/app/api/v1/routes/files.py new file mode 100644 index 0000000..0eb11cf --- /dev/null +++ b/app/api/v1/routes/files.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, UploadFile, File, Query +from fastapi.responses import FileResponse + +from app.db.mongo import get_db +from app.repositories.files_repo import FilesRepo +from app.services.storage import LocalStorage +from app.services.files_service import FilesService +from app.core.config import settings +from app.schemas.file import FileOut, FileListOut + +router = APIRouter(prefix="/files", tags=["files"]) + + +def get_service() -> FilesService: + db = get_db() + repo = FilesRepo(db) + storage = LocalStorage(settings.storage_dir) + return FilesService(repo, storage) + + +def to_out(doc: dict) -> FileOut: + file_id = doc["_id"] + return FileOut( + id=file_id, + original_name=doc["original_name"], + content_type=doc.get("content_type"), + size_bytes=doc["size_bytes"], + created_at=doc["created_at"], + url=f"/api/v1/files/{file_id}", + ) + + +@router.post("", response_model=FileOut) +async def upload_file( + file: UploadFile = File(...), + svc: FilesService = Depends(get_service), +): + doc = await svc.upload(file) + return to_out(doc) + + +@router.get("", response_model=FileListOut) +async def list_files( + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + svc: FilesService = Depends(get_service), +): + items, total = await svc.list_files(limit=limit, offset=offset) + return FileListOut(items=[to_out(d) for d in items], total=total) + + +@router.get("/{file_id}") +async def download_file( + file_id: str, + svc: FilesService = Depends(get_service), +): + doc = await svc.get_meta(file_id) + storage = LocalStorage(settings.storage_dir) + path = storage.path_for(doc["stored_name"]) + return FileResponse( + path=path, + media_type=doc.get("content_type") or "application/octet-stream", + filename=doc["original_name"], + ) + + +@router.delete("/{file_id}", status_code=204) +async def delete_file( + file_id: str, + svc: FilesService = Depends(get_service), +): + await svc.delete(file_id) + return None diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..f61fc68 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,22 @@ +from pydantic_settings import BaseSettings +from pydantic import Field + + +class Settings(BaseSettings): + app_name: str = Field(default="popa-site-backend", alias="APP_NAME") + app_env: str = Field(default="dev", alias="APP_ENV") + app_host: str = Field(default="0.0.0.0", alias="APP_HOST") + app_port: int = Field(default=3002, alias="APP_PORT") + log_level: str = Field(default="INFO", alias="LOG_LEVEL") + + mongo_uri: str = Field(alias="MONGO_URI") + mongo_db: str = Field(default="filehub", alias="MONGO_DB") + + storage_dir: str = Field(default="/data/uploads", alias="STORAGE_DIR") + max_upload_mb: int = Field(default=200, alias="MAX_UPLOAD_MB") + + class Config: + extra = "ignore" + + +settings = Settings() \ No newline at end of file diff --git a/app/db/mongo.py b/app/db/mongo.py new file mode 100644 index 0000000..f64cfe2 --- /dev/null +++ b/app/db/mongo.py @@ -0,0 +1,25 @@ +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +from app.core.config import settings + +_client: AsyncIOMotorClient | None = None +_db: AsyncIOMotorDatabase | None = None + + +async def connect_mongo() -> None: + global _client, _db + _client = AsyncIOMotorClient(settings.mongo_uri) + _db = _client[settings.mongo_db] + + +async def close_mongo() -> None: + global _client, _db + if _client is not None: + _client.close() + _client = None + _db = None + + +def get_db() -> AsyncIOMotorDatabase: + if _db is None: + raise RuntimeError("MongoDB is not connected") + return _db diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..fe33cdf --- /dev/null +++ b/app/main.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from app.core.config import settings +from app.db.mongo import connect_mongo, close_mongo +from app.api.v1 import api_router + +app = FastAPI(title=settings.app_name) + +app.include_router(api_router, prefix="/api/v1") + + +@app.on_event("startup") +async def _startup(): + await connect_mongo() + + +@app.on_event("shutdown") +async def _shutdown(): + await close_mongo() + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/app/models/file_meta.py b/app/models/file_meta.py new file mode 100644 index 0000000..ab0e84d --- /dev/null +++ b/app/models/file_meta.py @@ -0,0 +1,11 @@ +from datetime import datetime +from pydantic import BaseModel, Field + + +class FileMeta(BaseModel): + id: str = Field(..., description="UUID as string") + original_name: str + stored_name: str + content_type: str | None = None + size_bytes: int + created_at: datetime diff --git a/app/repositories/files_repo.py b/app/repositories/files_repo.py new file mode 100644 index 0000000..51aba39 --- /dev/null +++ b/app/repositories/files_repo.py @@ -0,0 +1,22 @@ +from motor.motor_asyncio import AsyncIOMotorDatabase + + +class FilesRepo: + def __init__(self, db: AsyncIOMotorDatabase): + self._col = db["files"] + + async def create(self, doc: dict) -> None: + await self._col.insert_one(doc) + + async def get(self, file_id: str) -> dict | None: + return await self._col.find_one({"_id": file_id}) + + async def list(self, limit: int, offset: int) -> tuple[list[dict], int]: + cursor = self._col.find({}).sort("created_at", -1).skip(offset).limit(limit) + items = await cursor.to_list(length=limit) + total = await self._col.count_documents({}) + return items, total + + async def delete(self, file_id: str) -> bool: + res = await self._col.delete_one({"_id": file_id}) + return res.deleted_count == 1 diff --git a/app/schemas/file.py b/app/schemas/file.py new file mode 100644 index 0000000..81862b3 --- /dev/null +++ b/app/schemas/file.py @@ -0,0 +1,16 @@ +from datetime import datetime +from pydantic import BaseModel + + +class FileOut(BaseModel): + id: str + original_name: str + content_type: str | None + size_bytes: int + created_at: datetime + url: str + + +class FileListOut(BaseModel): + items: list[FileOut] + total: int diff --git a/app/services/files_service.py b/app/services/files_service.py new file mode 100644 index 0000000..e006a0b --- /dev/null +++ b/app/services/files_service.py @@ -0,0 +1,55 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from fastapi import UploadFile, HTTPException, status + +from app.core.config import settings +from app.repositories.files_repo import FilesRepo +from app.services.storage import LocalStorage + + +class FilesService: + def __init__(self, repo: FilesRepo, storage: LocalStorage): + self.repo = repo + self.storage = storage + + async def upload(self, file: UploadFile) -> dict: + # лимит по размеру — считаем через чтение (для старта достаточно) + data = await file.read() + max_bytes = settings.max_upload_mb * 1024 * 1024 + if len(data) > max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File too large. Max is {settings.max_upload_mb} MB", + ) + + file_id = str(uuid4()) + stored_name = f"{file_id}" + + await self.storage.save(stored_name, data) + + doc = { + "_id": file_id, + "original_name": file.filename, + "stored_name": stored_name, + "content_type": file.content_type, + "size_bytes": len(data), + "created_at": datetime.now(timezone.utc), + } + await self.repo.create(doc) + return doc + + async def get_meta(self, file_id: str) -> dict: + doc = await self.repo.get(file_id) + if not doc: + raise HTTPException(status_code=404, detail="File not found") + return doc + + async def list_files(self, limit: int, offset: int) -> tuple[list[dict], int]: + return await self.repo.list(limit=limit, offset=offset) + + async def delete(self, file_id: str) -> None: + doc = await self.get_meta(file_id) + ok = await self.repo.delete(file_id) + if ok: + self.storage.delete(doc["stored_name"]) diff --git a/app/services/storage.py b/app/services/storage.py new file mode 100644 index 0000000..e78b076 --- /dev/null +++ b/app/services/storage.py @@ -0,0 +1,23 @@ +import os +from pathlib import Path +import aiofiles + + +class LocalStorage: + def __init__(self, base_dir: str): + self.base = Path(base_dir) + + async def save(self, stored_name: str, data: bytes) -> Path: + self.base.mkdir(parents=True, exist_ok=True) + path = self.base / stored_name + async with aiofiles.open(path, "wb") as f: + await f.write(data) + return path + + def path_for(self, stored_name: str) -> Path: + return self.base / stored_name + + def delete(self, stored_name: str) -> None: + path = self.path_for(stored_name) + if path.exists(): + os.remove(path) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3cac8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + api: + build: . + container_name: popa-site-backend + env_file: + - .env + ports: + - "3002:3002" + volumes: + - uploads:/data/uploads + depends_on: + - mongo + + mongo: + image: mongo:7 + container_name: popa-site-mongo + restart: unless-stopped + ports: + - "37017:27017" + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: + uploads: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6dafa9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +motor==3.7.1 +fastapi==0.124.4 +pydantic-settings==2.12.0 +pydantic==2.12.5 +aiofiles==25.1.0 +uvicorn==0.38.0 +python-multipart==0.0.20 \ No newline at end of file