add project structure

This commit is contained in:
2026-04-19 12:13:07 +02:00
commit dae7a60eab
16 changed files with 437 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
.pytest_cache/
*.pyc
data/*.db
+22
View File
@@ -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}"]
+185
View File
@@ -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
- 图片上传与处理
- 搜索
- 登录 / 鉴权
- 复杂前端样式
- 前后端分离
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -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()
+44
View File
@@ -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
View File
@@ -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()
+15
View File
@@ -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)
+20
View File
@@ -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;
}
+15
View File
@@ -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>
+7
View File
@@ -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 %}
+14
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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
+19
View File
@@ -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
+31
View File
@@ -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)