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