init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/venv/
|
||||
/.env
|
||||
/.idea/
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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
5
app/api/v1/__init__.py
Normal 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)
|
||||
73
app/api/v1/routes/files.py
Normal file
73
app/api/v1/routes/files.py
Normal 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
22
app/core/config.py
Normal 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
25
app/db/mongo.py
Normal 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
23
app/main.py
Normal 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
11
app/models/file_meta.py
Normal 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
|
||||
22
app/repositories/files_repo.py
Normal file
22
app/repositories/files_repo.py
Normal 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
16
app/schemas/file.py
Normal 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
|
||||
55
app/services/files_service.py
Normal file
55
app/services/files_service.py
Normal 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
23
app/services/storage.py
Normal 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
25
docker-compose.yml
Normal 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
7
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user