This commit is contained in:
aurinex
2025-12-15 14:08:07 +05:00
commit 32c34e78f7
14 changed files with 329 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/venv/
/.env
/.idea/

19
Dockerfile Normal file
View File

@ -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"]

5
app/api/v1/__init__.py Normal file
View File

@ -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)

View File

@ -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

22
app/core/config.py Normal file
View File

@ -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()

25
app/db/mongo.py Normal file
View File

@ -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

23
app/main.py Normal file
View File

@ -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"}

11
app/models/file_meta.py Normal file
View File

@ -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

View File

@ -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

16
app/schemas/file.py Normal file
View File

@ -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

View File

@ -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"])

23
app/services/storage.py Normal file
View File

@ -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)

25
docker-compose.yml Normal file
View File

@ -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:

7
requirements.txt Normal file
View File

@ -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