winebox 0.1.0__py3-none-any.whl
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.
- winebox/__init__.py +3 -0
- winebox/cli/__init__.py +1 -0
- winebox/cli/server.py +313 -0
- winebox/cli/user_admin.py +258 -0
- winebox/config.py +43 -0
- winebox/database.py +47 -0
- winebox/main.py +78 -0
- winebox/models/__init__.py +8 -0
- winebox/models/inventory.py +46 -0
- winebox/models/transaction.py +64 -0
- winebox/models/user.py +55 -0
- winebox/models/wine.py +66 -0
- winebox/routers/__init__.py +5 -0
- winebox/routers/auth.py +90 -0
- winebox/routers/cellar.py +102 -0
- winebox/routers/search.py +127 -0
- winebox/routers/transactions.py +63 -0
- winebox/routers/wines.py +287 -0
- winebox/schemas/__init__.py +13 -0
- winebox/schemas/transaction.py +40 -0
- winebox/schemas/wine.py +79 -0
- winebox/services/__init__.py +7 -0
- winebox/services/auth.py +123 -0
- winebox/services/image_storage.py +90 -0
- winebox/services/ocr.py +128 -0
- winebox/services/wine_parser.py +411 -0
- winebox/static/css/style.css +1086 -0
- winebox/static/index.html +271 -0
- winebox/static/js/app.js +703 -0
- winebox-0.1.0.dist-info/METADATA +283 -0
- winebox-0.1.0.dist-info/RECORD +34 -0
- winebox-0.1.0.dist-info/WHEEL +4 -0
- winebox-0.1.0.dist-info/entry_points.txt +3 -0
- winebox-0.1.0.dist-info/licenses/LICENSE +21 -0
winebox/__init__.py
ADDED
winebox/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI tools for WineBox."""
|
winebox/cli/server.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""WineBox server control script.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
winebox-server start [--port PORT] [--reload]
|
|
5
|
+
winebox-server stop
|
|
6
|
+
winebox-server restart [--port PORT]
|
|
7
|
+
winebox-server status
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Configuration
|
|
20
|
+
DATA_DIR = Path("data")
|
|
21
|
+
PID_FILE = DATA_DIR / "winebox.pid"
|
|
22
|
+
LOG_FILE = DATA_DIR / "winebox.log"
|
|
23
|
+
DEFAULT_PORT = 8000
|
|
24
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ensure_directories() -> None:
|
|
28
|
+
"""Ensure required directories exist."""
|
|
29
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
(DATA_DIR / "images").mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_pid() -> int | None:
|
|
34
|
+
"""Get the PID of the running server, if any."""
|
|
35
|
+
if not PID_FILE.exists():
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
pid = int(PID_FILE.read_text().strip())
|
|
40
|
+
# Check if process is actually running
|
|
41
|
+
os.kill(pid, 0)
|
|
42
|
+
return pid
|
|
43
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
44
|
+
# PID file exists but process is not running
|
|
45
|
+
PID_FILE.unlink(missing_ok=True)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_running_server() -> int | None:
|
|
50
|
+
"""Find any running winebox uvicorn process."""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["pgrep", "-f", "uvicorn winebox.main:app"],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
)
|
|
57
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
58
|
+
# Return first PID found
|
|
59
|
+
return int(result.stdout.strip().split()[0])
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def start_server(port: int = DEFAULT_PORT, host: str = DEFAULT_HOST,
|
|
66
|
+
reload: bool = False, foreground: bool = False) -> bool:
|
|
67
|
+
"""Start the WineBox server.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
port: Port to bind to
|
|
71
|
+
host: Host to bind to
|
|
72
|
+
reload: Enable auto-reload for development
|
|
73
|
+
foreground: Run in foreground (blocking)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if server started successfully
|
|
77
|
+
"""
|
|
78
|
+
# Check if already running
|
|
79
|
+
pid = get_pid() or find_running_server()
|
|
80
|
+
if pid:
|
|
81
|
+
print(f"Server is already running (PID: {pid})")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
ensure_directories()
|
|
85
|
+
|
|
86
|
+
cmd = [
|
|
87
|
+
sys.executable, "-m", "uvicorn",
|
|
88
|
+
"winebox.main:app",
|
|
89
|
+
"--host", host,
|
|
90
|
+
"--port", str(port),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
if reload:
|
|
94
|
+
cmd.append("--reload")
|
|
95
|
+
|
|
96
|
+
print(f"Starting WineBox server on http://{host}:{port}")
|
|
97
|
+
|
|
98
|
+
if foreground:
|
|
99
|
+
print("Press Ctrl+C to stop the server")
|
|
100
|
+
try:
|
|
101
|
+
subprocess.run(cmd)
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
print("\nServer stopped")
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# Start in background
|
|
107
|
+
with open(LOG_FILE, "w") as log:
|
|
108
|
+
process = subprocess.Popen(
|
|
109
|
+
cmd,
|
|
110
|
+
stdout=log,
|
|
111
|
+
stderr=subprocess.STDOUT,
|
|
112
|
+
start_new_session=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Wait a moment and check if it started
|
|
116
|
+
time.sleep(1)
|
|
117
|
+
if process.poll() is None:
|
|
118
|
+
PID_FILE.write_text(str(process.pid))
|
|
119
|
+
print(f"Server started with PID: {process.pid}")
|
|
120
|
+
print(f"Logs available at: {LOG_FILE}")
|
|
121
|
+
return True
|
|
122
|
+
else:
|
|
123
|
+
print("Failed to start server. Check logs for details.")
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def stop_server() -> bool:
|
|
128
|
+
"""Stop the WineBox server.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if server was stopped
|
|
132
|
+
"""
|
|
133
|
+
pid = get_pid()
|
|
134
|
+
|
|
135
|
+
if not pid:
|
|
136
|
+
# Try to find running server anyway
|
|
137
|
+
pid = find_running_server()
|
|
138
|
+
|
|
139
|
+
if not pid:
|
|
140
|
+
print("Server is not running")
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
print(f"Stopping server (PID: {pid})...")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# Send SIGTERM for graceful shutdown
|
|
147
|
+
os.kill(pid, signal.SIGTERM)
|
|
148
|
+
|
|
149
|
+
# Wait for process to terminate
|
|
150
|
+
for _ in range(10):
|
|
151
|
+
time.sleep(0.5)
|
|
152
|
+
try:
|
|
153
|
+
os.kill(pid, 0)
|
|
154
|
+
except ProcessLookupError:
|
|
155
|
+
break
|
|
156
|
+
else:
|
|
157
|
+
# Process didn't stop, send SIGKILL
|
|
158
|
+
print("Server didn't stop gracefully, forcing...")
|
|
159
|
+
os.kill(pid, signal.SIGKILL)
|
|
160
|
+
|
|
161
|
+
print("Server stopped")
|
|
162
|
+
PID_FILE.unlink(missing_ok=True)
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
except ProcessLookupError:
|
|
166
|
+
print("Server was not running")
|
|
167
|
+
PID_FILE.unlink(missing_ok=True)
|
|
168
|
+
return False
|
|
169
|
+
except PermissionError:
|
|
170
|
+
print(f"Permission denied to stop process {pid}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def restart_server(port: int = DEFAULT_PORT, host: str = DEFAULT_HOST) -> bool:
|
|
175
|
+
"""Restart the WineBox server.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
port: Port to bind to
|
|
179
|
+
host: Host to bind to
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if server restarted successfully
|
|
183
|
+
"""
|
|
184
|
+
print("Restarting WineBox server...")
|
|
185
|
+
stop_server()
|
|
186
|
+
time.sleep(1)
|
|
187
|
+
return start_server(port=port, host=host)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def server_status() -> None:
|
|
191
|
+
"""Print the server status."""
|
|
192
|
+
pid = get_pid()
|
|
193
|
+
|
|
194
|
+
if not pid:
|
|
195
|
+
pid = find_running_server()
|
|
196
|
+
|
|
197
|
+
if pid:
|
|
198
|
+
print(f"WineBox server is running (PID: {pid})")
|
|
199
|
+
|
|
200
|
+
# Try to get health status
|
|
201
|
+
try:
|
|
202
|
+
import urllib.request
|
|
203
|
+
with urllib.request.urlopen("http://localhost:8000/health", timeout=2) as response:
|
|
204
|
+
import json
|
|
205
|
+
data = json.loads(response.read().decode())
|
|
206
|
+
print(f" Status: {data.get('status', 'unknown')}")
|
|
207
|
+
print(f" Version: {data.get('version', 'unknown')}")
|
|
208
|
+
except Exception:
|
|
209
|
+
print(" (Could not fetch health status)")
|
|
210
|
+
else:
|
|
211
|
+
print("WineBox server is not running")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main() -> int:
|
|
215
|
+
"""Main entry point."""
|
|
216
|
+
parser = argparse.ArgumentParser(
|
|
217
|
+
description="WineBox server control script",
|
|
218
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
219
|
+
epilog="""
|
|
220
|
+
Examples:
|
|
221
|
+
%(prog)s start Start server on default port (8000)
|
|
222
|
+
%(prog)s start --port 8080 Start server on port 8080
|
|
223
|
+
%(prog)s start --reload Start with auto-reload for development
|
|
224
|
+
%(prog)s start --foreground Start in foreground (blocking)
|
|
225
|
+
%(prog)s stop Stop the server
|
|
226
|
+
%(prog)s restart Restart the server
|
|
227
|
+
%(prog)s status Check server status
|
|
228
|
+
""",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
232
|
+
|
|
233
|
+
# Start command
|
|
234
|
+
start_parser = subparsers.add_parser("start", help="Start the server")
|
|
235
|
+
start_parser.add_argument(
|
|
236
|
+
"--port", "-p",
|
|
237
|
+
type=int,
|
|
238
|
+
default=DEFAULT_PORT,
|
|
239
|
+
help=f"Port to bind to (default: {DEFAULT_PORT})",
|
|
240
|
+
)
|
|
241
|
+
start_parser.add_argument(
|
|
242
|
+
"--host",
|
|
243
|
+
default=DEFAULT_HOST,
|
|
244
|
+
help=f"Host to bind to (default: {DEFAULT_HOST})",
|
|
245
|
+
)
|
|
246
|
+
start_parser.add_argument(
|
|
247
|
+
"--reload", "-r",
|
|
248
|
+
action="store_true",
|
|
249
|
+
help="Enable auto-reload for development",
|
|
250
|
+
)
|
|
251
|
+
start_parser.add_argument(
|
|
252
|
+
"--foreground", "-f",
|
|
253
|
+
action="store_true",
|
|
254
|
+
help="Run in foreground (blocking)",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Stop command
|
|
258
|
+
subparsers.add_parser("stop", help="Stop the server")
|
|
259
|
+
|
|
260
|
+
# Restart command
|
|
261
|
+
restart_parser = subparsers.add_parser("restart", help="Restart the server")
|
|
262
|
+
restart_parser.add_argument(
|
|
263
|
+
"--port", "-p",
|
|
264
|
+
type=int,
|
|
265
|
+
default=DEFAULT_PORT,
|
|
266
|
+
help=f"Port to bind to (default: {DEFAULT_PORT})",
|
|
267
|
+
)
|
|
268
|
+
restart_parser.add_argument(
|
|
269
|
+
"--host",
|
|
270
|
+
default=DEFAULT_HOST,
|
|
271
|
+
help=f"Host to bind to (default: {DEFAULT_HOST})",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Status command
|
|
275
|
+
subparsers.add_parser("status", help="Check server status")
|
|
276
|
+
|
|
277
|
+
args = parser.parse_args()
|
|
278
|
+
|
|
279
|
+
if not args.command:
|
|
280
|
+
parser.print_help()
|
|
281
|
+
return 1
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
if args.command == "start":
|
|
285
|
+
success = start_server(
|
|
286
|
+
port=args.port,
|
|
287
|
+
host=args.host,
|
|
288
|
+
reload=args.reload,
|
|
289
|
+
foreground=args.foreground,
|
|
290
|
+
)
|
|
291
|
+
return 0 if success else 1
|
|
292
|
+
|
|
293
|
+
elif args.command == "stop":
|
|
294
|
+
success = stop_server()
|
|
295
|
+
return 0 if success else 1
|
|
296
|
+
|
|
297
|
+
elif args.command == "restart":
|
|
298
|
+
success = restart_server(port=args.port, host=args.host)
|
|
299
|
+
return 0 if success else 1
|
|
300
|
+
|
|
301
|
+
elif args.command == "status":
|
|
302
|
+
server_status()
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
except KeyboardInterrupt:
|
|
306
|
+
print("\nInterrupted")
|
|
307
|
+
return 130
|
|
308
|
+
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
sys.exit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""User administration script for WineBox.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
add Add a new user
|
|
5
|
+
list List all users
|
|
6
|
+
disable Disable a user account
|
|
7
|
+
enable Enable a user account
|
|
8
|
+
remove Remove a user account
|
|
9
|
+
passwd Change a user's password
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
from getpass import getpass
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
20
|
+
|
|
21
|
+
from winebox.config import settings
|
|
22
|
+
from winebox.database import Base
|
|
23
|
+
from winebox.models.user import User
|
|
24
|
+
from winebox.services.auth import get_password_hash
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_db_session() -> AsyncSession:
|
|
28
|
+
"""Create a database session, ensuring tables exist."""
|
|
29
|
+
engine = create_async_engine(settings.database_url, echo=False)
|
|
30
|
+
|
|
31
|
+
# Create tables if they don't exist
|
|
32
|
+
async with engine.begin() as conn:
|
|
33
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
34
|
+
|
|
35
|
+
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
36
|
+
return async_session()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def add_user(
|
|
40
|
+
username: str,
|
|
41
|
+
password: str,
|
|
42
|
+
email: Optional[str] = None,
|
|
43
|
+
is_admin: bool = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Add a new user."""
|
|
46
|
+
async with await get_db_session() as db:
|
|
47
|
+
# Check if user already exists
|
|
48
|
+
result = await db.execute(select(User).where(User.username == username))
|
|
49
|
+
if result.scalar_one_or_none():
|
|
50
|
+
print(f"Error: User '{username}' already exists.")
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
# Check if email already exists
|
|
54
|
+
if email:
|
|
55
|
+
result = await db.execute(select(User).where(User.email == email))
|
|
56
|
+
if result.scalar_one_or_none():
|
|
57
|
+
print(f"Error: Email '{email}' already in use.")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
# Create user
|
|
61
|
+
user = User(
|
|
62
|
+
username=username,
|
|
63
|
+
email=email,
|
|
64
|
+
hashed_password=get_password_hash(password),
|
|
65
|
+
is_admin=is_admin,
|
|
66
|
+
is_active=True,
|
|
67
|
+
)
|
|
68
|
+
db.add(user)
|
|
69
|
+
await db.commit()
|
|
70
|
+
|
|
71
|
+
role = "admin" if is_admin else "user"
|
|
72
|
+
print(f"User '{username}' created successfully as {role}.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def list_users() -> None:
|
|
76
|
+
"""List all users."""
|
|
77
|
+
async with await get_db_session() as db:
|
|
78
|
+
result = await db.execute(select(User).order_by(User.username))
|
|
79
|
+
users = result.scalars().all()
|
|
80
|
+
|
|
81
|
+
if not users:
|
|
82
|
+
print("No users found.")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
print(f"{'Username':<20} {'Email':<30} {'Admin':<6} {'Active':<6} {'Last Login':<20}")
|
|
86
|
+
print("-" * 90)
|
|
87
|
+
|
|
88
|
+
for user in users:
|
|
89
|
+
last_login = user.last_login.strftime("%Y-%m-%d %H:%M") if user.last_login else "Never"
|
|
90
|
+
admin = "Yes" if user.is_admin else "No"
|
|
91
|
+
active = "Yes" if user.is_active else "No"
|
|
92
|
+
email = user.email or ""
|
|
93
|
+
print(f"{user.username:<20} {email:<30} {admin:<6} {active:<6} {last_login:<20}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def disable_user(username: str) -> None:
|
|
97
|
+
"""Disable a user account."""
|
|
98
|
+
async with await get_db_session() as db:
|
|
99
|
+
result = await db.execute(select(User).where(User.username == username))
|
|
100
|
+
user = result.scalar_one_or_none()
|
|
101
|
+
|
|
102
|
+
if not user:
|
|
103
|
+
print(f"Error: User '{username}' not found.")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
if not user.is_active:
|
|
107
|
+
print(f"User '{username}' is already disabled.")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
user.is_active = False
|
|
111
|
+
await db.commit()
|
|
112
|
+
print(f"User '{username}' has been disabled.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def enable_user(username: str) -> None:
|
|
116
|
+
"""Enable a user account."""
|
|
117
|
+
async with await get_db_session() as db:
|
|
118
|
+
result = await db.execute(select(User).where(User.username == username))
|
|
119
|
+
user = result.scalar_one_or_none()
|
|
120
|
+
|
|
121
|
+
if not user:
|
|
122
|
+
print(f"Error: User '{username}' not found.")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
if user.is_active:
|
|
126
|
+
print(f"User '{username}' is already active.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
user.is_active = True
|
|
130
|
+
await db.commit()
|
|
131
|
+
print(f"User '{username}' has been enabled.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def remove_user(username: str, force: bool = False) -> None:
|
|
135
|
+
"""Remove a user account."""
|
|
136
|
+
async with await get_db_session() as db:
|
|
137
|
+
result = await db.execute(select(User).where(User.username == username))
|
|
138
|
+
user = result.scalar_one_or_none()
|
|
139
|
+
|
|
140
|
+
if not user:
|
|
141
|
+
print(f"Error: User '{username}' not found.")
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
|
|
144
|
+
if not force:
|
|
145
|
+
confirm = input(f"Are you sure you want to remove user '{username}'? [y/N]: ")
|
|
146
|
+
if confirm.lower() != "y":
|
|
147
|
+
print("Aborted.")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
await db.delete(user)
|
|
151
|
+
await db.commit()
|
|
152
|
+
print(f"User '{username}' has been removed.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def change_password(username: str, password: str) -> None:
|
|
156
|
+
"""Change a user's password."""
|
|
157
|
+
async with await get_db_session() as db:
|
|
158
|
+
result = await db.execute(select(User).where(User.username == username))
|
|
159
|
+
user = result.scalar_one_or_none()
|
|
160
|
+
|
|
161
|
+
if not user:
|
|
162
|
+
print(f"Error: User '{username}' not found.")
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
user.hashed_password = get_password_hash(password)
|
|
166
|
+
await db.commit()
|
|
167
|
+
print(f"Password for user '{username}' has been updated.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_password_interactive(confirm: bool = True) -> str:
|
|
171
|
+
"""Get password interactively from user."""
|
|
172
|
+
password = getpass("Password: ")
|
|
173
|
+
if not password:
|
|
174
|
+
print("Error: Password cannot be empty.")
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
if confirm:
|
|
178
|
+
password2 = getpass("Confirm password: ")
|
|
179
|
+
if password != password2:
|
|
180
|
+
print("Error: Passwords do not match.")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
return password
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main() -> int:
|
|
187
|
+
"""Main entry point."""
|
|
188
|
+
parser = argparse.ArgumentParser(
|
|
189
|
+
description="User administration for WineBox",
|
|
190
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
191
|
+
epilog=__doc__,
|
|
192
|
+
)
|
|
193
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
194
|
+
|
|
195
|
+
# Add user command
|
|
196
|
+
add_parser = subparsers.add_parser("add", help="Add a new user")
|
|
197
|
+
add_parser.add_argument("username", help="Username for the new user")
|
|
198
|
+
add_parser.add_argument("--email", "-e", help="Email address")
|
|
199
|
+
add_parser.add_argument("--admin", "-a", action="store_true", help="Make user an admin")
|
|
200
|
+
add_parser.add_argument("--password", "-p", help="Password (will prompt if not provided)")
|
|
201
|
+
|
|
202
|
+
# List users command
|
|
203
|
+
subparsers.add_parser("list", help="List all users")
|
|
204
|
+
|
|
205
|
+
# Disable user command
|
|
206
|
+
disable_parser = subparsers.add_parser("disable", help="Disable a user account")
|
|
207
|
+
disable_parser.add_argument("username", help="Username to disable")
|
|
208
|
+
|
|
209
|
+
# Enable user command
|
|
210
|
+
enable_parser = subparsers.add_parser("enable", help="Enable a user account")
|
|
211
|
+
enable_parser.add_argument("username", help="Username to enable")
|
|
212
|
+
|
|
213
|
+
# Remove user command
|
|
214
|
+
remove_parser = subparsers.add_parser("remove", help="Remove a user account")
|
|
215
|
+
remove_parser.add_argument("username", help="Username to remove")
|
|
216
|
+
remove_parser.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
217
|
+
|
|
218
|
+
# Change password command
|
|
219
|
+
passwd_parser = subparsers.add_parser("passwd", help="Change a user's password")
|
|
220
|
+
passwd_parser.add_argument("username", help="Username to change password for")
|
|
221
|
+
passwd_parser.add_argument("--password", "-p", help="New password (will prompt if not provided)")
|
|
222
|
+
|
|
223
|
+
args = parser.parse_args()
|
|
224
|
+
|
|
225
|
+
if not args.command:
|
|
226
|
+
parser.print_help()
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
if args.command == "add":
|
|
231
|
+
password = args.password if args.password else get_password_interactive()
|
|
232
|
+
asyncio.run(add_user(args.username, password, args.email, args.admin))
|
|
233
|
+
|
|
234
|
+
elif args.command == "list":
|
|
235
|
+
asyncio.run(list_users())
|
|
236
|
+
|
|
237
|
+
elif args.command == "disable":
|
|
238
|
+
asyncio.run(disable_user(args.username))
|
|
239
|
+
|
|
240
|
+
elif args.command == "enable":
|
|
241
|
+
asyncio.run(enable_user(args.username))
|
|
242
|
+
|
|
243
|
+
elif args.command == "remove":
|
|
244
|
+
asyncio.run(remove_user(args.username, args.force))
|
|
245
|
+
|
|
246
|
+
elif args.command == "passwd":
|
|
247
|
+
password = args.password if args.password else get_password_interactive()
|
|
248
|
+
asyncio.run(change_password(args.username, password))
|
|
249
|
+
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print("\nAborted.")
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
sys.exit(main())
|
winebox/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Configuration settings for WineBox application."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_secret_key() -> str:
|
|
9
|
+
"""Generate a random secret key."""
|
|
10
|
+
return secrets.token_urlsafe(32)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(BaseSettings):
|
|
14
|
+
"""Application settings."""
|
|
15
|
+
|
|
16
|
+
model_config = SettingsConfigDict(
|
|
17
|
+
env_prefix="WINEBOX_",
|
|
18
|
+
env_file=".env",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Application
|
|
22
|
+
app_name: str = "WineBox"
|
|
23
|
+
debug: bool = False
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
database_url: str = "sqlite+aiosqlite:///./data/winebox.db"
|
|
27
|
+
|
|
28
|
+
# Image storage
|
|
29
|
+
image_storage_path: Path = Path("data/images")
|
|
30
|
+
|
|
31
|
+
# Server
|
|
32
|
+
host: str = "0.0.0.0"
|
|
33
|
+
port: int = 8000
|
|
34
|
+
|
|
35
|
+
# OCR
|
|
36
|
+
tesseract_cmd: str | None = None # Use system default if None
|
|
37
|
+
|
|
38
|
+
# Authentication
|
|
39
|
+
secret_key: str = generate_secret_key() # Override with WINEBOX_SECRET_KEY env var
|
|
40
|
+
auth_enabled: bool = True # Set to False to disable authentication
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
settings = Settings()
|
winebox/database.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Database setup and session management."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
7
|
+
|
|
8
|
+
from winebox.config import settings
|
|
9
|
+
|
|
10
|
+
engine = create_async_engine(
|
|
11
|
+
settings.database_url,
|
|
12
|
+
echo=settings.debug,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
async_session_maker = async_sessionmaker(
|
|
16
|
+
engine,
|
|
17
|
+
class_=AsyncSession,
|
|
18
|
+
expire_on_commit=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Base(DeclarativeBase):
|
|
23
|
+
"""Base class for all database models."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
29
|
+
"""Dependency to get database session."""
|
|
30
|
+
async with async_session_maker() as session:
|
|
31
|
+
try:
|
|
32
|
+
yield session
|
|
33
|
+
await session.commit()
|
|
34
|
+
except Exception:
|
|
35
|
+
await session.rollback()
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def init_db() -> None:
|
|
40
|
+
"""Initialize the database, creating all tables."""
|
|
41
|
+
async with engine.begin() as conn:
|
|
42
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def close_db() -> None:
|
|
46
|
+
"""Close database connections."""
|
|
47
|
+
await engine.dispose()
|