From dae7a60eabc2dd8666297cb17ad4851dd5b3ec75 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 12:13:07 +0200 Subject: [PATCH] add project structure --- .gitignore | 5 ++ Dockerfile | 22 +++++ README.md | 185 +++++++++++++++++++++++++++++++++++++++ app/__init__.py | 1 + app/config.py | 13 +++ app/db.py | 44 ++++++++++ app/main.py | 37 ++++++++ app/models.py | 15 ++++ app/static/style.css | 20 +++++ app/templates/base.html | 15 ++++ app/templates/boxes.html | 7 ++ docker-compose.yml | 14 +++ pytest.ini | 3 + requirements.txt | 6 ++ tests/conftest.py | 19 ++++ tests/test_app.py | 31 +++++++ 16 files changed, 437 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/static/style.css create mode 100644 app/templates/base.html create mode 100644 app/templates/boxes.html create mode 100644 docker-compose.yml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dca966c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc +data/*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c82e15 --- /dev/null +++ b/Dockerfile @@ -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}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c544453 --- /dev/null +++ b/README.md @@ -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 +- 图片上传与处理 +- 搜索 +- 登录 / 鉴权 +- 复杂前端样式 +- 前后端分离 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..865b625 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..9e8934e --- /dev/null +++ b/app/db.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..41b1143 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..d2c61ae --- /dev/null +++ b/app/models.py @@ -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) + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..2ea7ea7 --- /dev/null +++ b/app/static/style.css @@ -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; +} + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..5866900 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,15 @@ + + + + + + {{ page_title or "Moving Helper" }} + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/boxes.html b/app/templates/boxes.html new file mode 100644 index 0000000..4f1ff43 --- /dev/null +++ b/app/templates/boxes.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +

Boxes page

+

This is the minimal starter page for the boxes module.

+{% endblock %} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..137114d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..faa3c97 --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..238ec86 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af0928d --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..489662c --- /dev/null +++ b/tests/test_app.py @@ -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)