add project structure
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
data/*.db
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=10000 \
|
||||
DATABASE_URL=sqlite:////app/data/app.db
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY tests ./tests
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 10000
|
||||
|
||||
CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-10000}"]
|
||||
@@ -0,0 +1,185 @@
|
||||
# Moving Helper Scaffold
|
||||
|
||||
这是一个面向可信家庭内网环境的小型工具项目,目前阶段只完成了基础脚手架:
|
||||
|
||||
- FastAPI
|
||||
- Jinja2
|
||||
- SQLAlchemy
|
||||
- SQLite
|
||||
- pytest / FastAPI TestClient
|
||||
- Docker / Docker Compose
|
||||
|
||||
当前还不是完整业务应用,现阶段重点是把运行方式、工程结构、配置和基础测试打稳,后续再继续补 CRUD、图片、搜索等能力。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── app
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py
|
||||
│ ├── db.py
|
||||
│ ├── main.py
|
||||
│ ├── models.py
|
||||
│ ├── static
|
||||
│ │ └── style.css
|
||||
│ └── templates
|
||||
│ ├── base.html
|
||||
│ └── boxes.html
|
||||
├── data
|
||||
├── tests
|
||||
│ ├── conftest.py
|
||||
│ └── test_app.py
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## 当前已接好的基础能力
|
||||
|
||||
- FastAPI 应用入口:`app/main.py`
|
||||
- Jinja2 模板渲染
|
||||
- 静态文件挂载:`/static`
|
||||
- `/` 自动重定向到 `/boxes`
|
||||
- SQLite 数据库连接
|
||||
- SQLAlchemy Base / Session
|
||||
- 启动时自动建表
|
||||
- 基础自动化测试
|
||||
|
||||
## 轻量配置
|
||||
|
||||
项目通过环境变量支持下面几个配置项:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `HOST`
|
||||
- `PORT`
|
||||
|
||||
默认值:
|
||||
|
||||
- `DATABASE_URL=sqlite:///./data/app.db`
|
||||
- `HOST=0.0.0.0`
|
||||
- `PORT=10000`
|
||||
|
||||
本地开发和 Docker 运行都可以直接使用这些默认值,也可以按需覆盖。
|
||||
|
||||
## 本地开发模式
|
||||
|
||||
推荐使用本地 Python `venv` 开发和调试。
|
||||
|
||||
### 1. 创建虚拟环境
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 启动开发服务器
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://localhost:10000
|
||||
```
|
||||
|
||||
本地开发默认会使用:
|
||||
|
||||
```text
|
||||
./data/app.db
|
||||
```
|
||||
|
||||
如果你想临时改端口或数据库路径,也可以这样运行:
|
||||
|
||||
```bash
|
||||
PORT=10000 DATABASE_URL=sqlite:///./data/app.db uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
|
||||
```
|
||||
|
||||
## Docker 部署模式
|
||||
|
||||
Docker / Compose 是这个项目面向长期运行环境的方式。
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://localhost:10000
|
||||
```
|
||||
|
||||
### 运行说明
|
||||
|
||||
- 默认暴露 `10000` 端口
|
||||
- `restart: unless-stopped`,适合长期运行
|
||||
- 容器内应用以用户 `1000:1000` 运行
|
||||
- SQLite 数据文件保存在宿主机 `./data/app.db`
|
||||
- 容器重建不会丢失数据库数据
|
||||
|
||||
### 数据持久化与备份
|
||||
|
||||
`docker-compose.yml` 会将宿主机目录:
|
||||
|
||||
```text
|
||||
./data
|
||||
```
|
||||
|
||||
挂载到容器内:
|
||||
|
||||
```text
|
||||
/app/data
|
||||
```
|
||||
|
||||
因此数据库文件通常位于:
|
||||
|
||||
```text
|
||||
./data/app.db
|
||||
```
|
||||
|
||||
备份时直接复制这个 SQLite 文件即可。
|
||||
|
||||
## 测试
|
||||
|
||||
这个项目已经接入了最基础的测试设施:
|
||||
|
||||
- `pytest`
|
||||
- `FastAPI TestClient`
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
测试会使用独立的测试数据库文件,不会污染本地开发使用的 `data/app.db`。
|
||||
|
||||
当前测试覆盖:
|
||||
|
||||
- 应用可正常启动
|
||||
- `/` 会重定向到 `/boxes`
|
||||
- `/boxes` 可正常返回 `200`
|
||||
- 测试数据库与真实开发数据库隔离
|
||||
|
||||
## 当前阶段未实现
|
||||
|
||||
这一轮仍然没有开始做完整业务功能,下面这些内容后续再补:
|
||||
|
||||
- Box / Item 完整 CRUD
|
||||
- 图片上传与处理
|
||||
- 搜索
|
||||
- 登录 / 鉴权
|
||||
- 复杂前端样式
|
||||
- 前后端分离
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
|
||||
host: str = os.getenv("HOST", "0.0.0.0")
|
||||
port: int = int(os.getenv("PORT", "10000"))
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
engine = None
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _build_engine(database_url: str):
|
||||
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||
return create_engine(database_url, connect_args=connect_args)
|
||||
|
||||
|
||||
def configure_database(database_url: str | None = None) -> None:
|
||||
global engine
|
||||
|
||||
settings = get_settings()
|
||||
resolved_database_url = database_url or settings.database_url
|
||||
engine = _build_engine(resolved_database_url)
|
||||
SessionLocal.configure(bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db(database_url: str | None = None) -> None:
|
||||
from app import models
|
||||
|
||||
if engine is None or database_url is not None:
|
||||
configure_database(database_url)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.db import init_db
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Moving Helper", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/boxes", status_code=302)
|
||||
|
||||
@app.get("/boxes")
|
||||
def boxes_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="boxes.html",
|
||||
context={"page_title": "Boxes"},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class Box(Base):
|
||||
__tablename__ = "boxes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="Sample Box")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f4f4f4;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 48px auto;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page_title or "Moving Helper" }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Boxes page</h1>
|
||||
<p>This is the minimal starter page for the boxes module.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
user: "1000:1000"
|
||||
ports:
|
||||
- "${PORT:-10000}:${PORT:-10000}"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: ${PORT:-10000}
|
||||
DATABASE_URL: sqlite:////app/data/app.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
filterwarnings =
|
||||
ignore:'asyncio\.iscoroutinefunction' is deprecated and slated for removal in Python 3\.16; use inspect\.iscoroutinefunction\(\) instead:DeprecationWarning:fastapi\.routing
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
jinja2==3.1.6
|
||||
sqlalchemy==2.0.43
|
||||
pytest==8.4.1
|
||||
httpx==0.28.1
|
||||
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db import configure_database
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path):
|
||||
test_db_path = tmp_path / "test.db"
|
||||
database_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
configure_database(database_url)
|
||||
app = create_app()
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db
|
||||
|
||||
|
||||
def test_app_starts(client):
|
||||
response = client.get("/boxes")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_root_redirects_to_boxes(client):
|
||||
response = client.get("/", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/boxes"
|
||||
|
||||
|
||||
def test_boxes_page_returns_200(client):
|
||||
response = client.get("/boxes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Boxes page" in response.text
|
||||
|
||||
|
||||
def test_tests_use_isolated_sqlite_database(client):
|
||||
assert db.engine is not None
|
||||
|
||||
database_name = Path(db.engine.url.database)
|
||||
assert database_name.name == "test.db"
|
||||
assert "data/app.db" not in str(database_name)
|
||||
Reference in New Issue
Block a user