paint-tracker 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paint_tracker-0.1.0/PKG-INFO +134 -0
- paint_tracker-0.1.0/README.md +112 -0
- paint_tracker-0.1.0/paint_tracker/__init__.py +5 -0
- paint_tracker-0.1.0/paint_tracker/__main__.py +5 -0
- paint_tracker-0.1.0/paint_tracker/cli.py +119 -0
- paint_tracker-0.1.0/paint_tracker/database.py +38 -0
- paint_tracker-0.1.0/paint_tracker/main.py +144 -0
- paint_tracker-0.1.0/paint_tracker/models.py +31 -0
- paint_tracker-0.1.0/paint_tracker/schemas.py +35 -0
- paint_tracker-0.1.0/paint_tracker/static/assets/index-BIhKSoZQ.css +1 -0
- paint_tracker-0.1.0/paint_tracker/static/assets/index-BUuXc0VQ.js +9 -0
- paint_tracker-0.1.0/paint_tracker/static/favicon.svg +1 -0
- paint_tracker-0.1.0/paint_tracker/static/icons.svg +24 -0
- paint_tracker-0.1.0/paint_tracker/static/index.html +14 -0
- paint_tracker-0.1.0/paint_tracker/updater.py +57 -0
- paint_tracker-0.1.0/paint_tracker.egg-info/PKG-INFO +134 -0
- paint_tracker-0.1.0/paint_tracker.egg-info/SOURCES.txt +21 -0
- paint_tracker-0.1.0/paint_tracker.egg-info/dependency_links.txt +1 -0
- paint_tracker-0.1.0/paint_tracker.egg-info/entry_points.txt +2 -0
- paint_tracker-0.1.0/paint_tracker.egg-info/requires.txt +4 -0
- paint_tracker-0.1.0/paint_tracker.egg-info/top_level.txt +1 -0
- paint_tracker-0.1.0/pyproject.toml +41 -0
- paint_tracker-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paint-tracker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Paint and stain shelf tracker – self-updating, one-command deploy for Raspberry Pi
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/FlaccidFacade/paint-tracker
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/FlaccidFacade/paint-tracker/issues
|
|
8
|
+
Keywords: paint,tracker,raspberry-pi,home
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Home Automation
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: fastapi>=0.115
|
|
19
|
+
Requires-Dist: uvicorn[standard]>=0.35
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
|
|
23
|
+
# paint-tracker
|
|
24
|
+
|
|
25
|
+
Paint and stain shelf tracker with:
|
|
26
|
+
|
|
27
|
+
- **FastAPI** API
|
|
28
|
+
- **SQLite** auto-configured database (no setup required)
|
|
29
|
+
- **React + Vite** frontend bundled into the Python package
|
|
30
|
+
- **Self-updating** – checks PyPI for new versions on every start
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 🚀 One-click install on Raspberry Pi Zero 2 W
|
|
35
|
+
|
|
36
|
+
Run this single command on the Pi (requires Python 3.11+ and internet access):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bash <(curl -fsSL https://raw.githubusercontent.com/FlaccidFacade/paint-tracker/main/scripts/install_pi.sh)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The script will:
|
|
43
|
+
1. Install `paint-tracker` from PyPI.
|
|
44
|
+
2. Create and enable a **systemd service** that starts automatically on every boot.
|
|
45
|
+
3. Open port 8000 in the firewall (if ufw is active).
|
|
46
|
+
4. Print the local URL where the UI is reachable (e.g. `http://raspberrypi.local:8000`).
|
|
47
|
+
|
|
48
|
+
After installation, the tracker is accessible from any device on your network.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 📦 Install from PyPI (any machine)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install paint-tracker
|
|
56
|
+
paint-tracker
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
On first launch Paint Tracker will:
|
|
60
|
+
- Check PyPI for a newer version and prompt you to upgrade.
|
|
61
|
+
- Auto-create the SQLite database at `~/.local/share/paint-tracker/paint_tracker.db`.
|
|
62
|
+
- Start the web server on `http://0.0.0.0:8000`.
|
|
63
|
+
|
|
64
|
+
### CLI options
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
paint-tracker --help
|
|
68
|
+
|
|
69
|
+
--host HOST Bind host (default: 0.0.0.0)
|
|
70
|
+
--port PORT Bind port (default: 8000)
|
|
71
|
+
--no-update-check Skip the PyPI update check on startup
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 🔧 Development setup
|
|
77
|
+
|
|
78
|
+
### Backend
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cd backend
|
|
82
|
+
pip install -r requirements.txt
|
|
83
|
+
pip install -e .. # install paint_tracker package in editable mode
|
|
84
|
+
uvicorn paint_tracker.main:app --reload
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`DATABASE_URL` defaults to `~/.local/share/paint-tracker/paint_tracker.db`. Override for PostgreSQL:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export DATABASE_URL='postgresql+psycopg://user:password@localhost:5432/paint_tracker'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Run tests:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd backend
|
|
97
|
+
pytest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Frontend (standalone dev server)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
cd frontend
|
|
104
|
+
npm install
|
|
105
|
+
npm run dev
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Optional API override:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
export VITE_API_BASE_URL='http://localhost:8000/api'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 📤 Publishing to PyPI
|
|
117
|
+
|
|
118
|
+
Build the frontend, bundle it into the Python package, and publish in one step:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install build twine
|
|
122
|
+
bash scripts/build_and_publish.sh # publish to PyPI
|
|
123
|
+
bash scripts/build_and_publish.sh --test # publish to TestPyPI
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Included behavior
|
|
129
|
+
|
|
130
|
+
- Create paints with required `room`, `shelf_level`, `shelf_depth`
|
|
131
|
+
- Room suggestions while typing; new room names are saved automatically
|
|
132
|
+
- Search paints by room, color, and coordinates
|
|
133
|
+
- Edit existing paints from the list (✏️ button)
|
|
134
|
+
- Confirmation/decline controls use green check and red X buttons
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# paint-tracker
|
|
2
|
+
|
|
3
|
+
Paint and stain shelf tracker with:
|
|
4
|
+
|
|
5
|
+
- **FastAPI** API
|
|
6
|
+
- **SQLite** auto-configured database (no setup required)
|
|
7
|
+
- **React + Vite** frontend bundled into the Python package
|
|
8
|
+
- **Self-updating** – checks PyPI for new versions on every start
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 🚀 One-click install on Raspberry Pi Zero 2 W
|
|
13
|
+
|
|
14
|
+
Run this single command on the Pi (requires Python 3.11+ and internet access):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bash <(curl -fsSL https://raw.githubusercontent.com/FlaccidFacade/paint-tracker/main/scripts/install_pi.sh)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The script will:
|
|
21
|
+
1. Install `paint-tracker` from PyPI.
|
|
22
|
+
2. Create and enable a **systemd service** that starts automatically on every boot.
|
|
23
|
+
3. Open port 8000 in the firewall (if ufw is active).
|
|
24
|
+
4. Print the local URL where the UI is reachable (e.g. `http://raspberrypi.local:8000`).
|
|
25
|
+
|
|
26
|
+
After installation, the tracker is accessible from any device on your network.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 📦 Install from PyPI (any machine)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install paint-tracker
|
|
34
|
+
paint-tracker
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
On first launch Paint Tracker will:
|
|
38
|
+
- Check PyPI for a newer version and prompt you to upgrade.
|
|
39
|
+
- Auto-create the SQLite database at `~/.local/share/paint-tracker/paint_tracker.db`.
|
|
40
|
+
- Start the web server on `http://0.0.0.0:8000`.
|
|
41
|
+
|
|
42
|
+
### CLI options
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
paint-tracker --help
|
|
46
|
+
|
|
47
|
+
--host HOST Bind host (default: 0.0.0.0)
|
|
48
|
+
--port PORT Bind port (default: 8000)
|
|
49
|
+
--no-update-check Skip the PyPI update check on startup
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🔧 Development setup
|
|
55
|
+
|
|
56
|
+
### Backend
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cd backend
|
|
60
|
+
pip install -r requirements.txt
|
|
61
|
+
pip install -e .. # install paint_tracker package in editable mode
|
|
62
|
+
uvicorn paint_tracker.main:app --reload
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`DATABASE_URL` defaults to `~/.local/share/paint-tracker/paint_tracker.db`. Override for PostgreSQL:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export DATABASE_URL='postgresql+psycopg://user:password@localhost:5432/paint_tracker'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Run tests:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cd backend
|
|
75
|
+
pytest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Frontend (standalone dev server)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cd frontend
|
|
82
|
+
npm install
|
|
83
|
+
npm run dev
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Optional API override:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
export VITE_API_BASE_URL='http://localhost:8000/api'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 📤 Publishing to PyPI
|
|
95
|
+
|
|
96
|
+
Build the frontend, bundle it into the Python package, and publish in one step:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install build twine
|
|
100
|
+
bash scripts/build_and_publish.sh # publish to PyPI
|
|
101
|
+
bash scripts/build_and_publish.sh --test # publish to TestPyPI
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Included behavior
|
|
107
|
+
|
|
108
|
+
- Create paints with required `room`, `shelf_level`, `shelf_depth`
|
|
109
|
+
- Room suggestions while typing; new room names are saved automatically
|
|
110
|
+
- Search paints by room, color, and coordinates
|
|
111
|
+
- Edit existing paints from the list (✏️ button)
|
|
112
|
+
- Confirmation/decline controls use green check and red X buttons
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Command-line entry point for the Paint Tracker server.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
paint-tracker [--host HOST] [--port PORT] [--no-update-check]
|
|
6
|
+
|
|
7
|
+
On startup the CLI will:
|
|
8
|
+
|
|
9
|
+
1. Check PyPI for a newer version and prompt the user to upgrade.
|
|
10
|
+
2. Auto-configure the database (SQLite at
|
|
11
|
+
``~/.local/share/paint-tracker/paint_tracker.db`` unless
|
|
12
|
+
``DATABASE_URL`` is set).
|
|
13
|
+
3. Launch the uvicorn ASGI server.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import importlib.metadata
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
from paint_tracker import __version__
|
|
24
|
+
from paint_tracker.updater import check_for_update
|
|
25
|
+
|
|
26
|
+
_BANNER = r"""
|
|
27
|
+
____ _ _ _____ _
|
|
28
|
+
| _ \ __ _(_)_ __ | |_ |_ _| __ __ _ __| | _____ _ __
|
|
29
|
+
| |_) / _` | | '_ \| __| | || '__/ _` |/ _` |/ / _ \ '__|
|
|
30
|
+
| __/ (_| | | | | | |_ | || | | (_| | (_| | < __/ |
|
|
31
|
+
|_| \__,_|_|_| |_|\__| |_||_| \__,_|\__,_|\_\___|_|
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _prompt_update(new_version: str) -> bool:
|
|
37
|
+
"""Return *True* if the user agrees to upgrade."""
|
|
38
|
+
print(f"\n🔔 A new version of paint-tracker is available: {new_version}")
|
|
39
|
+
print(f" Currently installed: {__version__}")
|
|
40
|
+
try:
|
|
41
|
+
answer = input(" Upgrade now? [Y/n] ").strip().lower()
|
|
42
|
+
except EOFError:
|
|
43
|
+
return False
|
|
44
|
+
return answer in ("", "y", "yes")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _do_upgrade() -> None:
|
|
48
|
+
"""Run ``pip install --upgrade paint-tracker`` and restart the process."""
|
|
49
|
+
print("⬆️ Upgrading paint-tracker …")
|
|
50
|
+
subprocess.check_call(
|
|
51
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "paint-tracker"],
|
|
52
|
+
stdout=sys.stdout,
|
|
53
|
+
stderr=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
print("✅ Upgrade complete – restarting …\n")
|
|
56
|
+
# Replace the current process with the freshly installed version.
|
|
57
|
+
import os
|
|
58
|
+
|
|
59
|
+
os.execv(sys.executable, [sys.executable, "-m", "paint_tracker"] + sys.argv[1:])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> None:
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
prog="paint-tracker",
|
|
65
|
+
description="Start the Paint Tracker server.",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
|
68
|
+
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--no-update-check",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Skip the PyPI update check on startup.",
|
|
73
|
+
)
|
|
74
|
+
args = parser.parse_args(argv)
|
|
75
|
+
|
|
76
|
+
print(_BANNER)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
installed = importlib.metadata.version("paint-tracker")
|
|
80
|
+
except importlib.metadata.PackageNotFoundError:
|
|
81
|
+
installed = __version__
|
|
82
|
+
print(f"Paint Tracker v{installed}")
|
|
83
|
+
|
|
84
|
+
# ── 1. Update check ──────────────────────────────────────────────────────
|
|
85
|
+
if not args.no_update_check:
|
|
86
|
+
print("🔍 Checking PyPI for updates …", end=" ", flush=True)
|
|
87
|
+
new_version = check_for_update()
|
|
88
|
+
if new_version:
|
|
89
|
+
print() # newline after the ellipsis
|
|
90
|
+
if _prompt_update(new_version):
|
|
91
|
+
_do_upgrade()
|
|
92
|
+
return # unreachable after execv, but keeps linters happy
|
|
93
|
+
else:
|
|
94
|
+
print("up to date.")
|
|
95
|
+
|
|
96
|
+
# ── 2. Database auto-configuration ───────────────────────────────────────
|
|
97
|
+
# Importing the database module triggers directory creation and engine setup.
|
|
98
|
+
from paint_tracker.database import DATABASE_URL # noqa: F401
|
|
99
|
+
|
|
100
|
+
print(f"\n🗄️ Database: {DATABASE_URL}")
|
|
101
|
+
|
|
102
|
+
# ── 3. Start server ───────────────────────────────────────────────────────
|
|
103
|
+
print(f"🚀 Starting server on http://{args.host}:{args.port}\n")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
import uvicorn
|
|
107
|
+
except ImportError as exc:
|
|
108
|
+
sys.exit(f"uvicorn is required to run the server: {exc}")
|
|
109
|
+
|
|
110
|
+
uvicorn.run(
|
|
111
|
+
"paint_tracker.main:app",
|
|
112
|
+
host=args.host,
|
|
113
|
+
port=args.port,
|
|
114
|
+
reload=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Database engine / session factory with automatic data-directory creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Generator
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import create_engine
|
|
10
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_db_path() -> str:
|
|
14
|
+
"""Return a sensible default SQLite path inside the user's data directory.
|
|
15
|
+
|
|
16
|
+
Priority:
|
|
17
|
+
1. ``DATABASE_URL`` environment variable (any SQLAlchemy URL).
|
|
18
|
+
2. ``~/.local/share/paint-tracker/paint_tracker.db`` (XDG-ish default).
|
|
19
|
+
"""
|
|
20
|
+
data_dir = Path.home() / ".local" / "share" / "paint-tracker"
|
|
21
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return f"sqlite:///{data_dir / 'paint_tracker.db'}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DATABASE_URL: str = os.getenv("DATABASE_URL") or _default_db_path()
|
|
26
|
+
|
|
27
|
+
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
|
28
|
+
|
|
29
|
+
engine = create_engine(DATABASE_URL, connect_args=connect_args, future=True)
|
|
30
|
+
session_local = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_db() -> Generator[Session, None, None]:
|
|
34
|
+
db = session_local()
|
|
35
|
+
try:
|
|
36
|
+
yield db
|
|
37
|
+
finally:
|
|
38
|
+
db.close()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from difflib import get_close_matches
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.orm import Session
|
|
11
|
+
|
|
12
|
+
from .database import engine, get_db
|
|
13
|
+
from .models import Base, Paint, Room
|
|
14
|
+
from .schemas import PaintCreate, PaintOut, PaintUpdate, RoomOut
|
|
15
|
+
|
|
16
|
+
Base.metadata.create_all(bind=engine)
|
|
17
|
+
|
|
18
|
+
app = FastAPI(title="Paint Tracker API")
|
|
19
|
+
|
|
20
|
+
app.add_middleware(
|
|
21
|
+
CORSMiddleware,
|
|
22
|
+
allow_origins=["*"],
|
|
23
|
+
allow_credentials=False,
|
|
24
|
+
allow_methods=["*"],
|
|
25
|
+
allow_headers=["*"],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_room_name(room_name: str) -> str:
|
|
30
|
+
return room_name.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_or_create_room(db: Session, room_name: str) -> Room:
|
|
34
|
+
normalized_name = _normalize_room_name(room_name)
|
|
35
|
+
existing = db.execute(select(Room).where(Room.name.ilike(normalized_name))).scalar_one_or_none()
|
|
36
|
+
if existing:
|
|
37
|
+
return existing
|
|
38
|
+
|
|
39
|
+
room = Room(name=normalized_name)
|
|
40
|
+
db.add(room)
|
|
41
|
+
db.flush()
|
|
42
|
+
return room
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _paint_to_schema(paint: Paint) -> PaintOut:
|
|
46
|
+
return PaintOut(
|
|
47
|
+
id=paint.id,
|
|
48
|
+
color_code=paint.color_code,
|
|
49
|
+
color_name=paint.color_name,
|
|
50
|
+
finish_style=paint.finish_style,
|
|
51
|
+
bucket_image_url=paint.bucket_image_url,
|
|
52
|
+
notes=paint.notes,
|
|
53
|
+
shelf_level=paint.shelf_level,
|
|
54
|
+
shelf_depth=paint.shelf_depth,
|
|
55
|
+
room=paint.room.name,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/api/paints", response_model=list[PaintOut])
|
|
60
|
+
def list_paints(
|
|
61
|
+
query: str | None = Query(default=None, max_length=100),
|
|
62
|
+
db: Session = Depends(get_db),
|
|
63
|
+
) -> list[PaintOut]:
|
|
64
|
+
statement = select(Paint).join(Room)
|
|
65
|
+
if query:
|
|
66
|
+
q = f"%{query.strip()}%"
|
|
67
|
+
statement = statement.where(
|
|
68
|
+
Paint.color_name.ilike(q)
|
|
69
|
+
| Paint.color_code.ilike(q)
|
|
70
|
+
| Room.name.ilike(q)
|
|
71
|
+
| Paint.shelf_level.ilike(q)
|
|
72
|
+
| Paint.shelf_depth.ilike(q)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
paints = db.execute(statement.order_by(Room.name, Paint.shelf_level, Paint.shelf_depth)).scalars().all()
|
|
76
|
+
return [_paint_to_schema(paint) for paint in paints]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.post("/api/paints", response_model=PaintOut, status_code=201)
|
|
80
|
+
def create_paint(payload: PaintCreate, db: Session = Depends(get_db)) -> PaintOut:
|
|
81
|
+
room = _get_or_create_room(db, payload.room)
|
|
82
|
+
paint = Paint(
|
|
83
|
+
color_code=payload.color_code,
|
|
84
|
+
color_name=payload.color_name,
|
|
85
|
+
finish_style=payload.finish_style,
|
|
86
|
+
bucket_image_url=payload.bucket_image_url,
|
|
87
|
+
notes=payload.notes,
|
|
88
|
+
shelf_level=payload.shelf_level,
|
|
89
|
+
shelf_depth=payload.shelf_depth,
|
|
90
|
+
room_id=room.id,
|
|
91
|
+
)
|
|
92
|
+
db.add(paint)
|
|
93
|
+
db.commit()
|
|
94
|
+
db.refresh(paint)
|
|
95
|
+
db.refresh(paint, attribute_names=["room"])
|
|
96
|
+
return _paint_to_schema(paint)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.put("/api/paints/{paint_id}", response_model=PaintOut)
|
|
100
|
+
def update_paint(paint_id: int, payload: PaintUpdate, db: Session = Depends(get_db)) -> PaintOut:
|
|
101
|
+
paint = db.get(Paint, paint_id)
|
|
102
|
+
if not paint:
|
|
103
|
+
raise HTTPException(status_code=404, detail="Paint not found")
|
|
104
|
+
|
|
105
|
+
room = _get_or_create_room(db, payload.room)
|
|
106
|
+
paint.color_code = payload.color_code
|
|
107
|
+
paint.color_name = payload.color_name
|
|
108
|
+
paint.finish_style = payload.finish_style
|
|
109
|
+
paint.bucket_image_url = payload.bucket_image_url
|
|
110
|
+
paint.notes = payload.notes
|
|
111
|
+
paint.shelf_level = payload.shelf_level
|
|
112
|
+
paint.shelf_depth = payload.shelf_depth
|
|
113
|
+
paint.room_id = room.id
|
|
114
|
+
|
|
115
|
+
db.commit()
|
|
116
|
+
db.refresh(paint)
|
|
117
|
+
db.refresh(paint, attribute_names=["room"])
|
|
118
|
+
return _paint_to_schema(paint)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.get("/api/rooms", response_model=list[RoomOut])
|
|
122
|
+
def list_rooms(
|
|
123
|
+
suggest: str | None = Query(default=None, max_length=30),
|
|
124
|
+
db: Session = Depends(get_db),
|
|
125
|
+
) -> list[RoomOut]:
|
|
126
|
+
rooms = db.execute(select(Room).order_by(Room.name)).scalars().all()
|
|
127
|
+
if not suggest:
|
|
128
|
+
return rooms
|
|
129
|
+
|
|
130
|
+
room_names = [room.name for room in rooms]
|
|
131
|
+
suggestion = suggest.strip()
|
|
132
|
+
prefix_matches = [name for name in room_names if suggestion.lower() in name.lower()]
|
|
133
|
+
close_matches = get_close_matches(suggestion, room_names, n=5, cutoff=0.5)
|
|
134
|
+
ordered_names = list(dict.fromkeys(prefix_matches + close_matches))
|
|
135
|
+
|
|
136
|
+
return [room for room in rooms if room.name in ordered_names]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Serve the pre-built React frontend when it exists inside the package.
|
|
140
|
+
# This mount must be registered AFTER all API routes so that API paths take
|
|
141
|
+
# priority over the catch-all "/" prefix handled by StaticFiles.
|
|
142
|
+
_STATIC_DIR = Path(__file__).parent / "static"
|
|
143
|
+
if (_STATIC_DIR / "index.html").exists():
|
|
144
|
+
app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import ForeignKey, String
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Base(DeclarativeBase):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Room(Base):
|
|
12
|
+
__tablename__ = "rooms"
|
|
13
|
+
|
|
14
|
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
15
|
+
name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
|
16
|
+
paints: Mapped[list["Paint"]] = relationship(back_populates="room", cascade="all, delete-orphan")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Paint(Base):
|
|
20
|
+
__tablename__ = "paints"
|
|
21
|
+
|
|
22
|
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
23
|
+
color_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
24
|
+
color_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
25
|
+
finish_style: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
26
|
+
bucket_image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
27
|
+
notes: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
28
|
+
shelf_level: Mapped[str] = mapped_column(String(10), index=True)
|
|
29
|
+
shelf_depth: Mapped[str] = mapped_column(String(10), index=True)
|
|
30
|
+
room_id: Mapped[int] = mapped_column(ForeignKey("rooms.id"), index=True)
|
|
31
|
+
room: Mapped[Room] = relationship(back_populates="paints")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PaintBase(BaseModel):
|
|
7
|
+
color_code: str | None = Field(default=None, max_length=50)
|
|
8
|
+
color_name: str | None = Field(default=None, max_length=100)
|
|
9
|
+
finish_style: str | None = Field(default=None, max_length=50)
|
|
10
|
+
bucket_image_url: str | None = Field(default=None, max_length=500)
|
|
11
|
+
notes: str | None = Field(default=None, max_length=500)
|
|
12
|
+
shelf_level: str = Field(min_length=1, max_length=10)
|
|
13
|
+
shelf_depth: str = Field(min_length=1, max_length=10)
|
|
14
|
+
room: str = Field(min_length=1, max_length=30)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PaintCreate(PaintBase):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaintUpdate(PaintBase):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PaintOut(PaintBase):
|
|
26
|
+
id: int
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(from_attributes=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RoomOut(BaseModel):
|
|
32
|
+
id: int
|
|
33
|
+
name: str
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(from_attributes=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color:#111827;background-color:#f3f4f6;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}body{margin:0}#root{min-height:100vh}.layout{gap:1rem;max-width:960px;margin:0 auto;padding:2rem 1rem 4rem;display:grid}.card{background:#fff;border-radius:12px;padding:1rem;box-shadow:0 3px 12px #11182714}.grid{grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:.75rem;display:grid}label{gap:.25rem;font-size:.9rem;display:grid}input,textarea,button{font:inherit}input,textarea{border:1px solid #d1d5db;border-radius:8px;padding:.5rem}textarea{min-height:72px}.full-width{grid-column:1/-1}.actions{gap:.5rem;margin-top:.75rem;display:flex}.actions button,.edit{cursor:pointer;border:none;border-radius:999px;width:2.25rem;height:2.25rem}.confirm{color:#fff;background-color:#22c55e}.decline{color:#fff;background-color:#ef4444}.edit{background-color:#e5e7eb}.search-row{gap:.5rem;margin-bottom:.75rem;display:flex}.search-row button{color:#fff;background-color:#111827;border:none;border-radius:8px;padding:.5rem .75rem}.paint-list{gap:.5rem;margin:0;padding:0;list-style:none;display:grid}.paint-list li{border:1px solid #e5e7eb;border-radius:8px;justify-content:space-between;align-items:center;padding:.75rem;display:flex}.paint-list p{margin:.1rem 0}.error{color:#b91c1c}
|