Spaces:
Sleeping
Sleeping
feat(core): implement unified database with multi-hotel support and session management
Browse files- Introduce unified SQLite database (Tabble.db) for all hotels
- Define comprehensive SQLAlchemy models for hotels, dishes, orders, persons, and related entities
- Implement DatabaseManager class to manage session-based connections and hotel context
- Support hotel authentication with password verification
- Ensure thread-safe database connection handling with locks and scoped sessions
- Add unique constraints for per-hotel user and loyalty configurations
- Automate table creation and session cleanup in database manager
- Prepare app structure for seamless multi-tenant data isolation within unified DB
This view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +73 -0
- .env.example +33 -0
- .gitattributes +0 -35
- .gitignore +68 -0
- DEMO_MODE_INSTRUCTIONS.md +139 -0
- DEPLOYMENT_SUMMARY.md +139 -0
- Dockerfile +44 -0
- HUGGINGFACE_DEPLOYMENT_GUIDE.md +93 -0
- README.md +712 -5
- README_HUGGINGFACE.md +17 -0
- TABLE_MANAGEMENT_IMPLEMENTATION.md +162 -0
- app.py +47 -0
- app/__init__.py +8 -0
- app/database.py +473 -0
- app/main.py +116 -0
- app/middleware/__init__.py +3 -0
- app/middleware/session_middleware.py +125 -0
- app/models/database_config.py +21 -0
- app/models/dish.py +42 -0
- app/models/feedback.py +22 -0
- app/models/loyalty.py +28 -0
- app/models/order.py +65 -0
- app/models/selection_offer.py +30 -0
- app/models/settings.py +34 -0
- app/models/table.py +33 -0
- app/models/user.py +39 -0
- app/routers/admin.py +643 -0
- app/routers/analytics.py +573 -0
- app/routers/chef.py +93 -0
- app/routers/customer.py +728 -0
- app/routers/feedback.py +87 -0
- app/routers/loyalty.py +172 -0
- app/routers/selection_offer.py +198 -0
- app/routers/settings.py +207 -0
- app/routers/table.py +254 -0
- app/services/__init__.py +1 -0
- app/services/firebase_auth.py +100 -0
- app/services/optimized_queries.py +313 -0
- app/static/images/README.md +27 -0
- app/static/images/dishes/7_50050-five-minute-ice-cream-DDMFS-4x3-076-fbf49ca6248e4dceb3f43a4f02823dd9.webp +0 -0
- app/static/images/dishes/7_download.webp +0 -0
- app/tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json +13 -0
- app/utils/__init__.py +1 -0
- app/utils/pdf_generator.py +285 -0
- hotels.csv +6 -0
- init_db.py +356 -0
- package-lock.json +6 -0
- render.yaml +14 -0
- requirements.txt +22 -0
- run.py +49 -0
.dockerignore
ADDED
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# .dockerignore for Tabble-v3
|
2 |
+
|
3 |
+
# Git
|
4 |
+
.git
|
5 |
+
.gitignore
|
6 |
+
README.md
|
7 |
+
|
8 |
+
# Python
|
9 |
+
__pycache__/
|
10 |
+
*.py[cod]
|
11 |
+
*$py.class
|
12 |
+
*.so
|
13 |
+
.Python
|
14 |
+
build/
|
15 |
+
develop-eggs/
|
16 |
+
dist/
|
17 |
+
downloads/
|
18 |
+
eggs/
|
19 |
+
.eggs/
|
20 |
+
lib/
|
21 |
+
lib64/
|
22 |
+
parts/
|
23 |
+
sdist/
|
24 |
+
var/
|
25 |
+
wheels/
|
26 |
+
*.egg-info/
|
27 |
+
.installed.cfg
|
28 |
+
*.egg
|
29 |
+
|
30 |
+
# Virtual environments
|
31 |
+
.env
|
32 |
+
.venv
|
33 |
+
env/
|
34 |
+
venv/
|
35 |
+
ENV/
|
36 |
+
env.bak/
|
37 |
+
venv.bak/
|
38 |
+
|
39 |
+
# IDE
|
40 |
+
.vscode/
|
41 |
+
.idea/
|
42 |
+
*.swp
|
43 |
+
*.swo
|
44 |
+
*~
|
45 |
+
|
46 |
+
# OS
|
47 |
+
.DS_Store
|
48 |
+
.DS_Store?
|
49 |
+
._*
|
50 |
+
.Spotlight-V100
|
51 |
+
.Trashes
|
52 |
+
ehthumbs.db
|
53 |
+
Thumbs.db
|
54 |
+
|
55 |
+
# Logs
|
56 |
+
*.log
|
57 |
+
logs/
|
58 |
+
|
59 |
+
# Temporary files
|
60 |
+
*.tmp
|
61 |
+
*.temp
|
62 |
+
|
63 |
+
# Documentation (already in image)
|
64 |
+
docs/
|
65 |
+
*.md
|
66 |
+
|
67 |
+
# Development only files
|
68 |
+
test-*.html
|
69 |
+
start.py
|
70 |
+
|
71 |
+
# Database backups
|
72 |
+
*.db.backup
|
73 |
+
backups/
|
.env.example
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Environment Variables for Tabble-v3
|
2 |
+
|
3 |
+
# Server Configuration
|
4 |
+
PORT=7860
|
5 |
+
HOST=0.0.0.0
|
6 |
+
|
7 |
+
# Security
|
8 |
+
SECRET_KEY=your_secret_key_here_change_in_production
|
9 |
+
|
10 |
+
# Application Settings
|
11 |
+
DEBUG=False
|
12 |
+
APP_NAME=Tabble-v3
|
13 |
+
APP_VERSION=3.1.0
|
14 |
+
|
15 |
+
# Database Configuration
|
16 |
+
# Note: SQLite databases are auto-created based on hotels.csv
|
17 |
+
|
18 |
+
# Firebase Configuration (Optional - for production phone authentication)
|
19 |
+
# FIREBASE_PRIVATE_KEY_ID=your_firebase_private_key_id
|
20 |
+
# FIREBASE_PRIVATE_KEY=your_firebase_private_key
|
21 |
+
# FIREBASE_CLIENT_EMAIL=your_firebase_client_email
|
22 |
+
# FIREBASE_CLIENT_ID=your_firebase_client_id
|
23 |
+
# FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
24 |
+
# FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
|
25 |
+
|
26 |
+
# Hugging Face Spaces
|
27 |
+
HUGGINGFACE_SPACES=1
|
28 |
+
|
29 |
+
# CORS Settings (for production, specify allowed origins)
|
30 |
+
ALLOWED_ORIGINS=["*"]
|
31 |
+
|
32 |
+
# Logging
|
33 |
+
LOG_LEVEL=INFO
|
.gitattributes
DELETED
@@ -1,35 +0,0 @@
|
|
1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Ignore binary image files
|
2 |
+
*.jpg
|
3 |
+
*.jpeg
|
4 |
+
*.png
|
5 |
+
*.gif
|
6 |
+
*.bmp
|
7 |
+
*.webp
|
8 |
+
*.ico
|
9 |
+
|
10 |
+
# Ignore specific image directories
|
11 |
+
app/static/images/dishes/*.jpg
|
12 |
+
app/static/images/dishes/*.jpeg
|
13 |
+
app/static/images/dishes/*.png
|
14 |
+
app/static/images/dishes/*.webp
|
15 |
+
app/static/images/logo/*.jpg
|
16 |
+
|
17 |
+
# Python
|
18 |
+
__pycache__/
|
19 |
+
*.py[cod]
|
20 |
+
*$py.class
|
21 |
+
*.so
|
22 |
+
.Python
|
23 |
+
build/
|
24 |
+
develop-eggs/
|
25 |
+
dist/
|
26 |
+
downloads/
|
27 |
+
eggs/
|
28 |
+
.eggs/
|
29 |
+
lib/
|
30 |
+
lib64/
|
31 |
+
parts/
|
32 |
+
sdist/
|
33 |
+
var/
|
34 |
+
wheels/
|
35 |
+
*.egg-info/
|
36 |
+
.installed.cfg
|
37 |
+
*.egg
|
38 |
+
|
39 |
+
# Virtual environments
|
40 |
+
.env
|
41 |
+
.venv
|
42 |
+
env/
|
43 |
+
venv/
|
44 |
+
ENV/
|
45 |
+
env.bak/
|
46 |
+
venv.bak/
|
47 |
+
|
48 |
+
# IDE
|
49 |
+
.vscode/
|
50 |
+
.idea/
|
51 |
+
*.swp
|
52 |
+
*.swo
|
53 |
+
|
54 |
+
# OS
|
55 |
+
.DS_Store
|
56 |
+
Thumbs.db
|
57 |
+
|
58 |
+
# Database files
|
59 |
+
*.db
|
60 |
+
*.sqlite
|
61 |
+
*.sqlite3
|
62 |
+
|
63 |
+
# Logs
|
64 |
+
*.log
|
65 |
+
logs/
|
66 |
+
|
67 |
+
# Firebase
|
68 |
+
*firebase*.json
|
DEMO_MODE_INSTRUCTIONS.md
ADDED
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Demo Mode Instructions
|
2 |
+
|
3 |
+
This document provides instructions for using the demo mode feature that bypasses customer authentication for demonstration purposes.
|
4 |
+
|
5 |
+
## Overview
|
6 |
+
|
7 |
+
Demo mode allows seamless access to the customer interface without requiring phone authentication. When enabled, accessing `/customer` will automatically redirect to the menu page with pre-configured demo credentials.
|
8 |
+
|
9 |
+
## Features
|
10 |
+
|
11 |
+
- **Automatic Redirect**: Visiting `/customer` automatically redirects to the menu with demo credentials
|
12 |
+
- **Demo Customer**: Uses hardcoded customer ID (999999) with username "Demo Customer"
|
13 |
+
- **Demo Indicator**: Shows "🎭 DEMO MODE ACTIVE" badge in the menu header
|
14 |
+
- **Preserved Functionality**: All menu features work normally (ordering, cart, etc.)
|
15 |
+
- **Easy Toggle**: Simple configuration flag to enable/disable
|
16 |
+
|
17 |
+
## How to Enable Demo Mode
|
18 |
+
|
19 |
+
### 1. Frontend Configuration
|
20 |
+
Edit `frontend/src/config/demoConfig.js`:
|
21 |
+
|
22 |
+
```javascript
|
23 |
+
export const DEMO_CONFIG = {
|
24 |
+
ENABLED: true, // Set to true to enable demo mode
|
25 |
+
// ... rest of config
|
26 |
+
};
|
27 |
+
```
|
28 |
+
|
29 |
+
### 2. Backend Configuration
|
30 |
+
Edit `app/routers/customer.py`:
|
31 |
+
|
32 |
+
```python
|
33 |
+
DEMO_MODE_ENABLED = True # Set to True to enable demo mode
|
34 |
+
```
|
35 |
+
|
36 |
+
## How to Disable Demo Mode
|
37 |
+
|
38 |
+
### 1. Frontend Configuration
|
39 |
+
Edit `frontend/src/config/demoConfig.js`:
|
40 |
+
|
41 |
+
```javascript
|
42 |
+
export const DEMO_CONFIG = {
|
43 |
+
ENABLED: false, // Set to false to disable demo mode
|
44 |
+
// ... rest of config
|
45 |
+
};
|
46 |
+
```
|
47 |
+
|
48 |
+
### 2. Backend Configuration
|
49 |
+
Edit `app/routers/customer.py`:
|
50 |
+
|
51 |
+
```python
|
52 |
+
DEMO_MODE_ENABLED = False # Set to False to disable demo mode
|
53 |
+
```
|
54 |
+
|
55 |
+
## Demo Credentials
|
56 |
+
|
57 |
+
When demo mode is active, the following credentials are used:
|
58 |
+
|
59 |
+
- **Customer ID**: 999999
|
60 |
+
- **Username**: Demo Customer
|
61 |
+
- **Phone**: +91-DEMO-USER
|
62 |
+
- **Table Number**: 1
|
63 |
+
- **Unique ID**: DEMO-SESSION-ID
|
64 |
+
- **Visit Count**: 5 (shows as returning customer)
|
65 |
+
|
66 |
+
## Usage Instructions
|
67 |
+
|
68 |
+
### For Demos
|
69 |
+
|
70 |
+
1. **Enable demo mode** using the configuration above
|
71 |
+
2. **Start the application** (both frontend and backend)
|
72 |
+
3. **Navigate to** `http://localhost:3000/customer`
|
73 |
+
4. **Automatic redirect** to menu page with demo credentials
|
74 |
+
5. **Demo indicator** will be visible in the header
|
75 |
+
6. **Use normally** - all features work as expected
|
76 |
+
|
77 |
+
### After Demo
|
78 |
+
|
79 |
+
1. **Disable demo mode** using the configuration above
|
80 |
+
2. **Restart the application** to apply changes
|
81 |
+
3. **Normal authentication** will be required again
|
82 |
+
|
83 |
+
## Technical Details
|
84 |
+
|
85 |
+
### Files Modified
|
86 |
+
|
87 |
+
- `frontend/src/config/demoConfig.js` - Demo configuration
|
88 |
+
- `frontend/src/pages/customer/Login.js` - Auto-redirect logic
|
89 |
+
- `frontend/src/pages/customer/components/HeroBanner.js` - Demo indicator
|
90 |
+
- `frontend/src/services/api.js` - Demo login API
|
91 |
+
- `app/routers/customer.py` - Demo login endpoint
|
92 |
+
|
93 |
+
### API Endpoint
|
94 |
+
|
95 |
+
- **POST** `/customer/api/demo-login`
|
96 |
+
- Creates or returns demo customer with ID 999999
|
97 |
+
- Only works when `DEMO_MODE_ENABLED = True`
|
98 |
+
|
99 |
+
### Security Notes
|
100 |
+
|
101 |
+
- Demo mode should **NEVER** be enabled in production
|
102 |
+
- Demo customer ID (999999) is designed to avoid conflicts
|
103 |
+
- All demo data is clearly marked and identifiable
|
104 |
+
- Easy to clean up demo data if needed
|
105 |
+
|
106 |
+
## Troubleshooting
|
107 |
+
|
108 |
+
### Demo Mode Not Working
|
109 |
+
|
110 |
+
1. Check both frontend and backend configuration flags
|
111 |
+
2. Restart both frontend and backend servers
|
112 |
+
3. Clear browser cache and localStorage
|
113 |
+
4. Check browser console for errors
|
114 |
+
|
115 |
+
### Demo Customer Not Created
|
116 |
+
|
117 |
+
1. Ensure backend demo mode is enabled
|
118 |
+
2. Check database connectivity
|
119 |
+
3. Verify hotel/database selection is working
|
120 |
+
4. Check backend logs for errors
|
121 |
+
|
122 |
+
### Normal Authentication Still Required
|
123 |
+
|
124 |
+
1. Verify frontend demo mode is enabled
|
125 |
+
2. Check that `isDemoModeEnabled()` returns true
|
126 |
+
3. Restart frontend development server
|
127 |
+
4. Clear browser cache
|
128 |
+
|
129 |
+
## Reverting Changes
|
130 |
+
|
131 |
+
To completely remove demo mode functionality:
|
132 |
+
|
133 |
+
1. Delete `frontend/src/config/demoConfig.js`
|
134 |
+
2. Remove demo imports from Login.js and HeroBanner.js
|
135 |
+
3. Remove demo login endpoint from customer.py
|
136 |
+
4. Remove demo login method from api.js
|
137 |
+
5. Restart both servers
|
138 |
+
|
139 |
+
This ensures a clean codebase without any demo-related code.
|
DEPLOYMENT_SUMMARY.md
ADDED
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 🎯 Tabble-v3 Hugging Face Deployment Summary
|
2 |
+
|
3 |
+
## ✅ Deployment Package Complete
|
4 |
+
|
5 |
+
Your Tabble-v3 restaurant management system is now **ready for Hugging Face Spaces deployment**!
|
6 |
+
|
7 |
+
### 📦 Created Files
|
8 |
+
|
9 |
+
| File | Purpose | Status |
|
10 |
+
|------|---------|--------|
|
11 |
+
| `Dockerfile` | Docker configuration for HF Spaces | ✅ Ready |
|
12 |
+
| `app.py` | Main entry point for the application | ✅ Ready |
|
13 |
+
| `requirements.txt` | Updated Python dependencies | ✅ Ready |
|
14 |
+
| `templates/` | Complete web interface templates | ✅ Ready |
|
15 |
+
| `.dockerignore` | Docker build optimization | ✅ Ready |
|
16 |
+
| `.env.example` | Environment variables template | ✅ Ready |
|
17 |
+
| `README_HUGGINGFACE.md` | HF Space metadata | ✅ Ready |
|
18 |
+
| `HUGGINGFACE_DEPLOYMENT_GUIDE.md` | Deployment instructions | ✅ Ready |
|
19 |
+
|
20 |
+
### 🌟 Key Features Ready
|
21 |
+
|
22 |
+
- **🍽️ Customer Interface**: QR code ordering with phone OTP
|
23 |
+
- **👨🍳 Chef Dashboard**: Real-time order management
|
24 |
+
- **🏨 Admin Panel**: Complete restaurant management
|
25 |
+
- **📊 Analytics**: Built-in performance tracking
|
26 |
+
- **🗄️ Multi-Database**: Support for multiple hotels
|
27 |
+
- **📱 Responsive Design**: Works on all devices
|
28 |
+
|
29 |
+
## 🚀 Quick Deploy Steps
|
30 |
+
|
31 |
+
### 1. Repository Setup
|
32 |
+
```bash
|
33 |
+
# Your repository is ready with all required files
|
34 |
+
# Just push to GitHub if you haven't already
|
35 |
+
git add .
|
36 |
+
git commit -m "Ready for Hugging Face Spaces deployment"
|
37 |
+
git push origin main
|
38 |
+
```
|
39 |
+
|
40 |
+
### 2. Create Hugging Face Space
|
41 |
+
1. Go to: https://huggingface.co/new-space
|
42 |
+
2. Choose: **Docker SDK**
|
43 |
+
3. Connect your GitHub repository
|
44 |
+
4. Deploy automatically!
|
45 |
+
|
46 |
+
### 3. Access Your App
|
47 |
+
Once deployed, your app will be available at:
|
48 |
+
```
|
49 |
+
https://[your-username]-tabble-v3-[space-name].hf.space
|
50 |
+
```
|
51 |
+
|
52 |
+
## 🎮 Demo Credentials
|
53 |
+
|
54 |
+
### For Testing
|
55 |
+
- **Hotel Access Code**: `myhotel`
|
56 |
+
- **Demo Database**: `tabble_new.db`
|
57 |
+
- **Table Numbers**: 1-20
|
58 |
+
- **Phone OTP**: Any 6 digits (demo mode)
|
59 |
+
|
60 |
+
### Admin Panel
|
61 |
+
- Go to `/admin`
|
62 |
+
- Enter hotel code: `myhotel`
|
63 |
+
- Manage dishes, orders, settings
|
64 |
+
|
65 |
+
## 🔧 Production Customization
|
66 |
+
|
67 |
+
### Add Your Restaurant
|
68 |
+
1. Edit `hotels.csv`:
|
69 |
+
```csv
|
70 |
+
hotel_database,password
|
71 |
+
your_restaurant.db,your_secure_password
|
72 |
+
```
|
73 |
+
|
74 |
+
2. Access admin panel and configure:
|
75 |
+
- Hotel information
|
76 |
+
- Menu items with images
|
77 |
+
- Pricing and categories
|
78 |
+
- Loyalty programs
|
79 |
+
|
80 |
+
## 📊 What You Get
|
81 |
+
|
82 |
+
### Customer Experience
|
83 |
+
- **QR Code Ordering**: Scan table QR codes
|
84 |
+
- **Phone Authentication**: Secure OTP login
|
85 |
+
- **Real-time Cart**: Live order updates
|
86 |
+
- **Payment Integration**: Ready for payment gateways
|
87 |
+
|
88 |
+
### Staff Tools
|
89 |
+
- **Chef Dashboard**: Live order notifications
|
90 |
+
- **Admin Panel**: Complete management control
|
91 |
+
- **Analytics**: Customer and sales insights
|
92 |
+
- **Multi-Hotel**: Independent databases
|
93 |
+
|
94 |
+
### Technical Features
|
95 |
+
- **FastAPI Backend**: High-performance API
|
96 |
+
- **SQLite Databases**: One per hotel
|
97 |
+
- **Responsive Templates**: Mobile-friendly
|
98 |
+
- **Real-time Updates**: Live order tracking
|
99 |
+
|
100 |
+
## 🌍 Ready for Global Use
|
101 |
+
|
102 |
+
Your system supports:
|
103 |
+
- **Multiple Languages**: Easily customizable templates
|
104 |
+
- **Different Currencies**: Update symbols in templates
|
105 |
+
- **Local Regulations**: Configurable tax and pricing
|
106 |
+
- **Custom Branding**: Upload logos and customize colors
|
107 |
+
|
108 |
+
## 📈 Scaling Options
|
109 |
+
|
110 |
+
### Hugging Face Spaces Tiers
|
111 |
+
- **Free Tier**: Perfect for testing and small restaurants
|
112 |
+
- **Pro Tier**: Enhanced performance and resources
|
113 |
+
- **Enterprise**: Dedicated resources and support
|
114 |
+
|
115 |
+
### External Integrations
|
116 |
+
- **Payment Gateways**: Stripe, PayPal, local providers
|
117 |
+
- **SMS Services**: Twilio, AWS SNS for real OTP
|
118 |
+
- **Cloud Storage**: AWS S3 for dish images
|
119 |
+
- **Analytics**: Google Analytics, custom tracking
|
120 |
+
|
121 |
+
## 🆘 Support Resources
|
122 |
+
|
123 |
+
- **API Documentation**: Available at `/docs` in your deployed app
|
124 |
+
- **GitHub Repository**: Issues and discussions
|
125 |
+
- **Deployment Guide**: `HUGGINGFACE_DEPLOYMENT_GUIDE.md`
|
126 |
+
- **Technical Docs**: Complete README with all features
|
127 |
+
|
128 |
+
## 🎉 Next Steps
|
129 |
+
|
130 |
+
1. **Deploy**: Create your Hugging Face Space
|
131 |
+
2. **Test**: Verify all interfaces work
|
132 |
+
3. **Customize**: Add your restaurant details
|
133 |
+
4. **Launch**: Start serving customers!
|
134 |
+
|
135 |
+
---
|
136 |
+
|
137 |
+
**Your modern restaurant management system is ready to revolutionize your business operations!**
|
138 |
+
|
139 |
+
Deploy now and start serving customers with cutting-edge technology! 🚀🍽️
|
Dockerfile
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use Python 3.11 slim image as base
|
2 |
+
FROM python:3.11-slim
|
3 |
+
|
4 |
+
# Set environment variables
|
5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
6 |
+
PYTHONUNBUFFERED=1 \
|
7 |
+
PORT=7860
|
8 |
+
|
9 |
+
# Set work directory
|
10 |
+
WORKDIR /app
|
11 |
+
|
12 |
+
# Install system dependencies
|
13 |
+
RUN apt-get update && apt-get install -y \
|
14 |
+
gcc \
|
15 |
+
g++ \
|
16 |
+
&& rm -rf /var/lib/apt/lists/*
|
17 |
+
|
18 |
+
# Copy requirements first for better caching
|
19 |
+
COPY requirements.txt .
|
20 |
+
|
21 |
+
# Install Python dependencies
|
22 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
23 |
+
pip install --no-cache-dir -r requirements.txt
|
24 |
+
|
25 |
+
# Copy the application code
|
26 |
+
COPY . .
|
27 |
+
|
28 |
+
# Create necessary directories
|
29 |
+
RUN mkdir -p app/static/images/dishes && \
|
30 |
+
mkdir -p templates && \
|
31 |
+
chmod 755 app/static/images
|
32 |
+
|
33 |
+
# Initialize the database with sample data
|
34 |
+
RUN python init_db.py
|
35 |
+
|
36 |
+
# Expose port 7860 (Hugging Face Spaces default)
|
37 |
+
EXPOSE 7860
|
38 |
+
|
39 |
+
# Health check
|
40 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 \
|
41 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
42 |
+
|
43 |
+
# Start the application
|
44 |
+
CMD ["python", "app.py"]
|
HUGGINGFACE_DEPLOYMENT_GUIDE.md
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 🤗 Hugging Face Spaces Deployment Guide for Tabble-v3
|
2 |
+
|
3 |
+
## 🚀 Quick Deployment
|
4 |
+
|
5 |
+
### Step 1: Prepare Repository
|
6 |
+
```bash
|
7 |
+
git clone https://github.com/your-username/tabble-v3.git
|
8 |
+
cd tabble-v3
|
9 |
+
|
10 |
+
# Verify required files exist:
|
11 |
+
# ✓ Dockerfile, app.py, requirements.txt
|
12 |
+
# ✓ templates/, app/, hotels.csv
|
13 |
+
```
|
14 |
+
|
15 |
+
### Step 2: Create Hugging Face Space
|
16 |
+
1. Go to [https://huggingface.co/new-space](https://huggingface.co/new-space)
|
17 |
+
2. Configure:
|
18 |
+
- **Space name**: `tabble-v3-restaurant`
|
19 |
+
- **SDK**: Docker
|
20 |
+
- **License**: MIT
|
21 |
+
3. Connect your GitHub repository
|
22 |
+
4. Add metadata to README.md:
|
23 |
+
|
24 |
+
```yaml
|
25 |
+
---
|
26 |
+
title: Tabble-v3 Restaurant Management
|
27 |
+
emoji: 🍽️
|
28 |
+
colorFrom: blue
|
29 |
+
colorTo: purple
|
30 |
+
sdk: docker
|
31 |
+
app_port: 7860
|
32 |
+
license: mit
|
33 |
+
tags: [restaurant, management, fastapi, qr-code]
|
34 |
+
---
|
35 |
+
```
|
36 |
+
|
37 |
+
### Step 3: Monitor Build
|
38 |
+
- Watch build logs in "Logs" tab
|
39 |
+
- Wait for "Running on http://0.0.0.0:7860" message
|
40 |
+
- Verify health check at `/health` endpoint
|
41 |
+
|
42 |
+
## 📱 Using Your App
|
43 |
+
|
44 |
+
### Access URLs
|
45 |
+
- **Home**: `your-space-url/`
|
46 |
+
- **Customer**: `your-space-url/customer`
|
47 |
+
- **Chef**: `your-space-url/chef`
|
48 |
+
- **Admin**: `your-space-url/admin`
|
49 |
+
- **API Docs**: `your-space-url/docs`
|
50 |
+
|
51 |
+
### Demo Credentials
|
52 |
+
- **Hotel Access Code**: `myhotel`
|
53 |
+
- **Tables**: 1-20
|
54 |
+
- **Phone OTP**: Any 6 digits
|
55 |
+
|
56 |
+
## 🔧 Customization
|
57 |
+
|
58 |
+
### Add Your Hotel
|
59 |
+
Edit `hotels.csv`:
|
60 |
+
```csv
|
61 |
+
hotel_database,password
|
62 |
+
your_restaurant.db,secure_password_123
|
63 |
+
```
|
64 |
+
|
65 |
+
### Menu Setup
|
66 |
+
1. Access admin panel with hotel code
|
67 |
+
2. Go to "Manage Dishes"
|
68 |
+
3. Add menu items with images and pricing
|
69 |
+
|
70 |
+
## 🚑 Troubleshooting
|
71 |
+
|
72 |
+
### Common Issues
|
73 |
+
1. **Build fails**: Check Dockerfile and requirements.txt
|
74 |
+
2. **App won't start**: Verify port 7860 and database init
|
75 |
+
3. **Templates missing**: Ensure templates/ directory exists
|
76 |
+
|
77 |
+
### Debug Steps
|
78 |
+
1. Check Space logs for errors
|
79 |
+
2. Test `/health` endpoint
|
80 |
+
3. Verify admin panel loads with demo credentials
|
81 |
+
|
82 |
+
## 📊 Production Ready
|
83 |
+
|
84 |
+
### Security
|
85 |
+
- Update hotel passwords in `hotels.csv`
|
86 |
+
- Set `SECRET_KEY` environment variable
|
87 |
+
|
88 |
+
### Features
|
89 |
+
- Upload menu images to `app/static/images/`
|
90 |
+
- Customize branding in templates
|
91 |
+
- Configure real SMS/payment services
|
92 |
+
|
93 |
+
**Your restaurant management system is now live!** 🎉
|
README.md
CHANGED
@@ -1,10 +1,717 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
|
|
7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
-
|
|
|
1 |
+
# Tabble-v3
|
2 |
+
|
3 |
+
A modern restaurant management system built with Python FastAPI and React.
|
4 |
+
A comprehensive restaurant management system built with FastAPI (backend) and React (frontend), featuring QR code-based table ordering, phone OTP authentication, real-time order management, and multi-database support for independent hotel operations.
|
5 |
+
|
6 |
+
## 🚀 Quick Deploy on Hugging Face Spaces
|
7 |
+
|
8 |
+
[](https://huggingface.co/new-space?template=your-username/tabble-v3)
|
9 |
+
|
10 |
+
Tabble-v3 is now optimized for **Hugging Face Spaces** deployment! Deploy your restaurant management system with just a few clicks.
|
11 |
+
|
12 |
+
### 🌐 Live Demo
|
13 |
+
- **Demo URL**: [Your Space URL after deployment]
|
14 |
+
- **Admin Panel**: `/admin` (use access code: `myhotel`)
|
15 |
+
- **Chef Dashboard**: `/chef`
|
16 |
+
- **Customer Interface**: `/customer`
|
17 |
+
- **API Documentation**: `/docs`
|
18 |
+
|
19 |
+
### 📦 One-Click Deployment
|
20 |
+
|
21 |
+
1. **Fork this repository** to your GitHub account
|
22 |
+
2. **Go to [Hugging Face Spaces](https://huggingface.co/new-space)**
|
23 |
+
3. **Select "Docker" as the SDK**
|
24 |
+
4. **Connect your GitHub repository**
|
25 |
+
5. **Wait for automatic build and deployment**
|
26 |
+
6. **Access your live restaurant management system!**
|
27 |
+
|
28 |
+
That's it! Your restaurant management system will be live and accessible to the world.
|
29 |
+
|
30 |
+
### 🔧 Environment Configuration
|
31 |
+
|
32 |
+
The application comes pre-configured for Hugging Face Spaces with:
|
33 |
+
- **Port**: 7860 (Hugging Face Spaces default)
|
34 |
+
- **Demo Database**: Pre-loaded with sample data
|
35 |
+
- **Sample Hotel**: "Demo Hotel" with access code `myhotel`
|
36 |
+
- **Templates**: Complete web interface included
|
37 |
+
|
38 |
+
### 🏨 Multi-Hotel Setup
|
39 |
+
|
40 |
+
To add your own hotel:
|
41 |
+
|
42 |
+
1. **Edit `hotels.csv`**:
|
43 |
+
```csv
|
44 |
+
hotel_database,password
|
45 |
+
tabble_new.db,myhotel
|
46 |
+
your_hotel.db,your_password
|
47 |
+
```
|
48 |
+
|
49 |
+
2. **Create database** (optional - auto-created on first access):
|
50 |
+
```bash
|
51 |
+
python create_empty_db.py
|
52 |
+
```
|
53 |
+
|
54 |
+
3. **Restart your Space** to apply changes
|
55 |
+
|
56 |
+
### 📱 Usage Guide
|
57 |
+
|
58 |
+
#### For Customers:
|
59 |
+
1. Scan QR code at your table or visit `/customer`
|
60 |
+
2. Select your hotel and enter access code
|
61 |
+
3. Enter table number and phone number
|
62 |
+
4. Verify OTP and start ordering!
|
63 |
+
|
64 |
+
#### For Restaurant Staff:
|
65 |
+
- **Chefs**: Visit `/chef` for order management
|
66 |
+
- **Admins**: Visit `/admin` for complete restaurant control
|
67 |
+
- **API**: Visit `/docs` for API documentation
|
68 |
+
|
69 |
+
## 🌟 Key Features
|
70 |
+
|
71 |
+
### 🍽️ Customer Interface
|
72 |
+
- **Phone OTP Authentication**: Secure Firebase-based authentication
|
73 |
+
- **Real-time Cart Management**: Live cart updates with special offers
|
74 |
+
- **Today's Specials**: Dynamic special dish recommendations
|
75 |
+
- **Payment Processing**: Integrated payment with loyalty discounts
|
76 |
+
- **Order History**: Track past orders and preferences
|
77 |
+
|
78 |
+
### 👨🍳 Chef Dashboard
|
79 |
+
- **Real-time Order Management**: Live order notifications and updates
|
80 |
+
- **Kitchen Operations**: Streamlined order acceptance and completion
|
81 |
+
- **Order Status Updates**: Instant status changes reflected across all interfaces
|
82 |
+
|
83 |
+
### 🏨 Admin Panel
|
84 |
+
- **Complete Restaurant Management**: Full control over restaurant operations
|
85 |
+
- **Dish Management**: Add, edit, and manage menu items with images
|
86 |
+
- **Offers & Specials**: Create and manage promotional offers
|
87 |
+
- **Table Management**: Monitor table occupancy and status
|
88 |
+
- **Order Tracking**: Complete order lifecycle management
|
89 |
+
- **Loyalty Program**: Configurable visit-based discount system
|
90 |
+
- **Selection Offers**: Amount-based discount configuration
|
91 |
+
- **Settings**: Hotel information and configuration management
|
92 |
+
|
93 |
+
### 📊 Analytics Dashboard
|
94 |
+
- **Customer Analysis**: Detailed customer behavior insights
|
95 |
+
- **Dish Performance**: Menu item popularity and sales metrics
|
96 |
+
- **Chef Performance**: Kitchen efficiency tracking
|
97 |
+
- **Sales & Revenue**: Comprehensive financial reporting
|
98 |
+
|
99 |
+
### 🗄️ Multi-Database Support
|
100 |
+
- **Independent Hotel Operations**: Each hotel operates with its own database
|
101 |
+
- **Database Authentication**: Secure database access with password protection
|
102 |
+
- **Session-based Management**: Consistent database context across all interfaces
|
103 |
+
- **Data Isolation**: Complete separation of hotel data for security and privacy
|
104 |
+
|
105 |
+
## 📁 Project Structure
|
106 |
+
|
107 |
+
```
|
108 |
+
tabble/
|
109 |
+
├── app/ # Backend FastAPI application
|
110 |
+
│ ├── database.py # Database configuration and models
|
111 |
+
│ ├── main.py # FastAPI application entry point
|
112 |
+
│ ├── middleware/ # Custom middleware (CORS, session handling)
|
113 |
+
│ ├── models/ # SQLAlchemy database models
|
114 |
+
│ ├── routers/ # API route definitions
|
115 |
+
│ │ ├── admin.py # Admin panel endpoints
|
116 |
+
│ │ ├── chef.py # Chef dashboard endpoints
|
117 |
+
│ │ ├── customer.py # Customer interface endpoints
|
118 |
+
│ │ └── analytics.py # Analytics and reporting endpoints
|
119 |
+
│ ├── services/ # Business logic and external services
|
120 |
+
│ │ ├── firebase_service.py # Firebase authentication
|
121 |
+
│ │ └── database_service.py # Database operations
|
122 |
+
│ ├── static/ # Static file serving
|
123 |
+
│ │ └── images/ # Dish and hotel logo images
|
124 |
+
│ │ └── dishes/ # Organized by database name
|
125 |
+
│ └── utils/ # Utility functions and helpers
|
126 |
+
├── frontend/ # React frontend application
|
127 |
+
│ ├── src/
|
128 |
+
│ │ ├── components/ # Reusable React components
|
129 |
+
│ │ │ ├── Layout.js # Main layout wrapper
|
130 |
+
│ │ │ ├── AdminLayout.js # Admin panel layout
|
131 |
+
│ │ │ └── ChefLayout.js # Chef dashboard layout
|
132 |
+
│ │ ├── pages/ # Page components
|
133 |
+
│ │ │ ├── admin/ # Admin interface pages
|
134 |
+
│ │ │ ├── chef/ # Chef dashboard pages
|
135 |
+
│ │ │ ├── customer/ # Customer interface pages
|
136 |
+
│ │ │ └── analysis/ # Analytics dashboard
|
137 |
+
│ │ ├── services/ # API communication services
|
138 |
+
│ │ │ └── api.js # Axios configuration and API calls
|
139 |
+
│ │ ├── App.js # Main React application
|
140 |
+
│ │ ├── index.js # React DOM entry point
|
141 |
+
│ │ └── global.css # Global styling
|
142 |
+
│ ├── public/ # Static assets
|
143 |
+
│ │ ├── index.html # HTML template
|
144 |
+
│ │ └── favicon.ico # Application icon
|
145 |
+
│ ├── package.json # Node.js dependencies
|
146 |
+
│ ├── .env.example # Environment variables template
|
147 |
+
│ └── .env # Environment configuration
|
148 |
+
├── templates/ # Report generation templates
|
149 |
+
│ └── analysis/ # Analytics report templates
|
150 |
+
├── hotels.csv # Database registry and passwords
|
151 |
+
├── init_db.py # Database initialization with sample data
|
152 |
+
├── create_empty_db.py # Empty database creation utility
|
153 |
+
├── requirements.txt # Python dependencies
|
154 |
+
├── run.py # Backend server launcher
|
155 |
+
└── README.md # Project documentation
|
156 |
+
```
|
157 |
+
|
158 |
+
## 🚀 Quick Start Guide
|
159 |
+
|
160 |
+
### Prerequisites
|
161 |
+
|
162 |
+
#### For Windows:
|
163 |
+
- **Python 3.8+**: Download from [python.org](https://www.python.org/downloads/)
|
164 |
+
- **Node.js 16+**: Download from [nodejs.org](https://nodejs.org/downloads/)
|
165 |
+
- **Git**: Download from [git-scm.com](https://git-scm.com/downloads)
|
166 |
+
|
167 |
+
#### For macOS:
|
168 |
+
- **Python 3.8+**: Install via Homebrew: `brew install python3`
|
169 |
+
- **Node.js 16+**: Install via Homebrew: `brew install node`
|
170 |
+
- **Git**: Install via Homebrew: `brew install git`
|
171 |
+
|
172 |
+
### 🔧 Installation & Setup
|
173 |
+
|
174 |
+
#### 1. Clone the Repository
|
175 |
+
```bash
|
176 |
+
git clone <repository-url>
|
177 |
+
cd tabble
|
178 |
+
```
|
179 |
+
|
180 |
+
#### 2. Backend Setup
|
181 |
+
|
182 |
+
##### Windows:
|
183 |
+
```cmd
|
184 |
+
# Create virtual environment
|
185 |
+
python -m venv venv
|
186 |
+
|
187 |
+
# Activate virtual environment
|
188 |
+
venv\Scripts\activate
|
189 |
+
|
190 |
+
# Install dependencies
|
191 |
+
pip install -r requirements.txt
|
192 |
+
```
|
193 |
+
|
194 |
+
##### macOS/Linux:
|
195 |
+
```bash
|
196 |
+
# Create virtual environment
|
197 |
+
python3 -m venv venv
|
198 |
+
|
199 |
+
# Activate virtual environment
|
200 |
+
source venv/bin/activate
|
201 |
+
|
202 |
+
# Install dependencies
|
203 |
+
pip install -r requirements.txt
|
204 |
+
```
|
205 |
+
|
206 |
+
#### 3. Frontend Setup
|
207 |
+
|
208 |
+
##### Both Windows and macOS:
|
209 |
+
```bash
|
210 |
+
# Navigate to frontend directory
|
211 |
+
cd frontend
|
212 |
+
|
213 |
+
# Copy environment template
|
214 |
+
cp .env.example .env
|
215 |
+
|
216 |
+
# Install Node.js dependencies
|
217 |
+
npm install
|
218 |
+
```
|
219 |
+
|
220 |
+
#### 4. Configure Environment Variables
|
221 |
+
|
222 |
+
##### Backend (.env in root directory):
|
223 |
+
```env
|
224 |
+
SECRET_KEY=your_secret_key_here
|
225 |
+
```
|
226 |
+
|
227 |
+
##### Frontend (frontend/.env):
|
228 |
+
```env
|
229 |
+
# Backend API Configuration
|
230 |
+
REACT_APP_API_BASE_URL=http://localhost:8000
|
231 |
+
|
232 |
+
# Development settings
|
233 |
+
NODE_ENV=development
|
234 |
+
|
235 |
+
# Firebase Configuration (optional)
|
236 |
+
# REACT_APP_FIREBASE_API_KEY=your_firebase_api_key
|
237 |
+
# REACT_APP_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
|
238 |
+
# REACT_APP_FIREBASE_PROJECT_ID=your_project_id
|
239 |
+
```
|
240 |
+
|
241 |
+
## 🗄️ Database Management
|
242 |
+
|
243 |
+
### Understanding the Multi-Database System
|
244 |
+
|
245 |
+
Tabble supports multiple independent hotel databases, allowing each hotel to operate with complete data isolation. Each database contains:
|
246 |
+
|
247 |
+
- **Dishes**: Menu items with pricing, categories, and images
|
248 |
+
- **Orders**: Customer orders and order items
|
249 |
+
- **Persons**: Customer information and visit history
|
250 |
+
- **Tables**: Table management and occupancy status
|
251 |
+
- **Loyalty Program**: Visit-based discount tiers
|
252 |
+
- **Selection Offers**: Amount-based promotional offers
|
253 |
+
- **Settings**: Hotel-specific configuration
|
254 |
+
- **Feedback**: Customer reviews and ratings
|
255 |
+
|
256 |
+
### Database Registry (hotels.csv)
|
257 |
+
|
258 |
+
The `hotels.csv` file serves as the central registry for all hotel databases:
|
259 |
+
|
260 |
+
```csv
|
261 |
+
hotel_database,password
|
262 |
+
tabble_new.db,myhotel
|
263 |
+
|
264 |
+
```
|
265 |
+
|
266 |
+
### Creating a New Hotel Database
|
267 |
+
|
268 |
+
#### Method 1: Using the create_empty_db.py Script
|
269 |
+
|
270 |
+
##### Windows:
|
271 |
+
```cmd
|
272 |
+
# Activate virtual environment
|
273 |
+
venv\Scripts\activate
|
274 |
+
|
275 |
+
# Run the database creation script
|
276 |
+
python create_empty_db.py
|
277 |
+
```
|
278 |
+
|
279 |
+
##### macOS/Linux:
|
280 |
+
```bash
|
281 |
+
# Activate virtual environment
|
282 |
+
source venv/bin/activate
|
283 |
+
|
284 |
+
# Run the database creation script
|
285 |
+
python create_empty_db.py
|
286 |
+
```
|
287 |
+
|
288 |
+
**Interactive Process:**
|
289 |
+
1. The script will prompt you for a database name
|
290 |
+
2. Enter the hotel name (without .db extension)
|
291 |
+
3. The script creates an empty database with proper schema
|
292 |
+
4. Manually add the database entry to `hotels.csv`
|
293 |
+
|
294 |
+
**Example:**
|
295 |
+
```
|
296 |
+
Creating a new empty database with the proper schema
|
297 |
+
Enter name for the new database (without .db extension): newhotel
|
298 |
+
|
299 |
+
Success! Created empty database 'newhotel.db' with the proper schema
|
300 |
+
```
|
301 |
+
|
302 |
+
Then add to `hotels.csv`:
|
303 |
+
```csv
|
304 |
+
newhotel.db,newhotel123
|
305 |
+
```
|
306 |
+
|
307 |
+
#### Method 2: Initialize with Sample Data
|
308 |
+
|
309 |
+
##### Windows:
|
310 |
+
```cmd
|
311 |
+
# Create database with sample data
|
312 |
+
python init_db.py
|
313 |
+
```
|
314 |
+
|
315 |
+
##### macOS/Linux:
|
316 |
+
```bash
|
317 |
+
# Create database with sample data
|
318 |
+
python init_db.py
|
319 |
+
```
|
320 |
+
|
321 |
+
**Note:** This creates `tabble_new.db` with sample dishes, users, and configuration.
|
322 |
+
|
323 |
+
### Database Schema Details
|
324 |
+
|
325 |
+
The `create_empty_db.py` script creates the following tables:
|
326 |
+
|
327 |
+
#### Core Tables:
|
328 |
+
- **dishes**: Menu items with pricing, categories, offers, and visibility
|
329 |
+
- **persons**: Customer profiles with visit tracking
|
330 |
+
- **orders**: Order management with status tracking
|
331 |
+
- **order_items**: Individual items within orders
|
332 |
+
- **tables**: Table management and occupancy status
|
333 |
+
|
334 |
+
#### Configuration Tables:
|
335 |
+
- **loyalty_program**: Visit-based discount configuration
|
336 |
+
- **selection_offers**: Amount-based promotional offers
|
337 |
+
- **settings**: Hotel information and branding
|
338 |
+
- **feedback**: Customer reviews and ratings
|
339 |
+
|
340 |
+
### Running the Application
|
341 |
+
|
342 |
+
#### Start Backend Server
|
343 |
+
|
344 |
+
##### Windows:
|
345 |
+
```cmd
|
346 |
+
# Activate virtual environment
|
347 |
+
venv\Scripts\activate
|
348 |
+
|
349 |
+
# Start the FastAPI server
|
350 |
+
python run.py
|
351 |
+
```
|
352 |
+
|
353 |
+
##### macOS/Linux:
|
354 |
+
```bash
|
355 |
+
# Activate virtual environment
|
356 |
+
source venv/bin/activate
|
357 |
+
|
358 |
+
# Start the FastAPI server
|
359 |
+
python run.py
|
360 |
+
```
|
361 |
+
|
362 |
+
The backend will be available at `http://localhost:8000`
|
363 |
+
|
364 |
+
#### Start Frontend Development Server
|
365 |
+
|
366 |
+
##### Both Windows and macOS:
|
367 |
+
```bash
|
368 |
+
# Navigate to frontend directory
|
369 |
+
cd frontend
|
370 |
+
|
371 |
+
# Start React development server
|
372 |
+
npm start
|
373 |
+
```
|
374 |
+
|
375 |
+
The frontend will be available at `http://localhost:3000`
|
376 |
+
|
377 |
+
### 🔗 API Documentation
|
378 |
+
|
379 |
+
Once the backend is running, access the interactive API documentation:
|
380 |
+
- **Swagger UI**: `http://localhost:8000/docs`
|
381 |
+
- **ReDoc**: `http://localhost:8000/redoc`
|
382 |
+
|
383 |
+
## 🎯 Key Features Implementation
|
384 |
+
|
385 |
+
### 🍽️ Table Management
|
386 |
+
- **QR Code Generation**: Automatic QR code creation for each table
|
387 |
+
- **Real-time Status Monitoring**: Live table occupancy tracking
|
388 |
+
- **Session-based Occupancy**: Table status changes based on customer interaction
|
389 |
+
- **Multi-database Support**: Table management per hotel database
|
390 |
+
|
391 |
+
### 📱 Order Processing
|
392 |
+
- **Real-time Order Tracking**: Live order status updates across all interfaces
|
393 |
+
- **Kitchen Notifications**: Instant order notifications to chef dashboard
|
394 |
+
- **Status Synchronization**: Order status changes reflect immediately
|
395 |
+
- **Payment Integration**: Secure payment processing with loyalty discounts
|
396 |
+
|
397 |
+
### 📊 Analytics and Reporting
|
398 |
+
- **Custom Report Templates**: Configurable analytics reports
|
399 |
+
- **PDF Generation**: Automated report exports
|
400 |
+
- **Performance Metrics**: Comprehensive business intelligence
|
401 |
+
- **Multi-dimensional Analysis**: Customer, dish, and chef performance tracking
|
402 |
+
|
403 |
+
### 🔐 Authentication & Security
|
404 |
+
- **Firebase Phone OTP**: Secure customer authentication
|
405 |
+
- **Database Password Protection**: Hotel database access control
|
406 |
+
- **Session Management**: Secure session handling across interfaces
|
407 |
+
- **Data Isolation**: Complete separation of hotel data
|
408 |
+
|
409 |
+
## 🚨 Troubleshooting
|
410 |
+
|
411 |
+
### Common Issues
|
412 |
+
|
413 |
+
#### Backend Issues:
|
414 |
+
```bash
|
415 |
+
# If you get "Module not found" errors
|
416 |
+
pip install -r requirements.txt
|
417 |
+
|
418 |
+
# If database connection fails
|
419 |
+
python create_empty_db.py
|
420 |
+
|
421 |
+
# If port 8000 is already in use
|
422 |
+
# Edit run.py and change the port number
|
423 |
+
```
|
424 |
+
|
425 |
+
#### Frontend Issues:
|
426 |
+
```bash
|
427 |
+
# If npm install fails
|
428 |
+
npm cache clean --force
|
429 |
+
npm install
|
430 |
+
|
431 |
+
# If environment variables aren't loading
|
432 |
+
# Check that .env file exists in frontend directory
|
433 |
+
cp .env.example .env
|
434 |
+
|
435 |
+
# If API calls fail
|
436 |
+
# Verify REACT_APP_API_BASE_URL in frontend/.env
|
437 |
+
```
|
438 |
+
|
439 |
+
#### Database Issues:
|
440 |
+
```bash
|
441 |
+
# If database schema is outdated
|
442 |
+
python init_db.py --force-reset
|
443 |
+
|
444 |
+
# If hotels.csv is missing entries
|
445 |
+
# Manually add your database to hotels.csv
|
446 |
+
```
|
447 |
+
|
448 |
+
### Platform-Specific Notes
|
449 |
+
|
450 |
+
#### Windows:
|
451 |
+
- Use `venv\Scripts\activate` to activate virtual environment
|
452 |
+
- Use `python` command (not `python3`)
|
453 |
+
- Ensure Python is added to PATH during installation
|
454 |
+
|
455 |
+
#### macOS:
|
456 |
+
- Use `source venv/bin/activate` to activate virtual environment
|
457 |
+
- Use `python3` command for Python 3.x
|
458 |
+
- Install Xcode Command Line Tools if needed: `xcode-select --install`
|
459 |
+
|
460 |
+
## 🔄 Development Workflow
|
461 |
+
|
462 |
+
### Adding a New Hotel Database
|
463 |
+
|
464 |
+
1. **Create Empty Database:**
|
465 |
+
```bash
|
466 |
+
python create_empty_db.py
|
467 |
+
```
|
468 |
+
|
469 |
+
2. **Add to Registry:**
|
470 |
+
Edit `hotels.csv` and add your database entry:
|
471 |
+
```csv
|
472 |
+
yourhotel.db,yourpassword123
|
473 |
+
```
|
474 |
+
|
475 |
+
3. **Configure Hotel Settings:**
|
476 |
+
- Access admin panel: `http://localhost:3000/admin`
|
477 |
+
- Navigate to Settings
|
478 |
+
- Configure hotel information
|
479 |
+
|
480 |
+
4. **Add Menu Items:**
|
481 |
+
- Use admin panel to add dishes
|
482 |
+
- Upload dish images to `app/static/images/dishes/yourhotel/`
|
483 |
+
|
484 |
+
### Deployment Considerations
|
485 |
+
|
486 |
+
#### Production Environment Variables:
|
487 |
+
```env
|
488 |
+
# Backend
|
489 |
+
SECRET_KEY=your_production_secret_key
|
490 |
+
DATABASE_URL=your_production_database_url
|
491 |
+
|
492 |
+
# Frontend
|
493 |
+
REACT_APP_API_BASE_URL=https://your-domain.com/api
|
494 |
+
NODE_ENV=production
|
495 |
+
```
|
496 |
+
|
497 |
+
#### Image Storage:
|
498 |
+
- Images are stored in `app/static/images/dishes/{database_name}/`
|
499 |
+
- Ensure proper directory permissions for image uploads
|
500 |
+
- Consider using cloud storage for production deployments
|
501 |
+
|
502 |
+
## 🤗 Hugging Face Spaces Deployment Guide
|
503 |
+
|
504 |
+
### 🚀 Quick Deployment Steps
|
505 |
+
|
506 |
+
#### 1. Prepare Your Repository
|
507 |
+
```bash
|
508 |
+
# Clone or fork this repository
|
509 |
+
git clone https://github.com/your-username/tabble-v3.git
|
510 |
+
cd tabble-v3
|
511 |
+
|
512 |
+
# Ensure all required files are present:
|
513 |
+
# ✓ Dockerfile
|
514 |
+
# ✓ app.py
|
515 |
+
# ✓ requirements.txt
|
516 |
+
# ✓ templates/
|
517 |
+
# ✓ app/
|
518 |
+
```
|
519 |
+
|
520 |
+
#### 2. Create Hugging Face Space
|
521 |
+
|
522 |
+
1. **Go to**: [https://huggingface.co/new-space](https://huggingface.co/new-space)
|
523 |
+
2. **Fill in details**:
|
524 |
+
- **Space name**: `tabble-v3-restaurant` (or your preferred name)
|
525 |
+
- **License**: MIT
|
526 |
+
- **SDK**: Docker
|
527 |
+
- **Visibility**: Public (or Private)
|
528 |
+
3. **Connect Repository**: Link your GitHub repository
|
529 |
+
4. **Create Space**
|
530 |
+
|
531 |
+
#### 3. Configure Space Settings
|
532 |
+
|
533 |
+
Add this to your Space's `README.md` header:
|
534 |
+
```yaml
|
535 |
---
|
536 |
+
title: Tabble-v3 Restaurant Management
|
537 |
+
emoji: 🍽️
|
538 |
+
colorFrom: blue
|
539 |
+
colorTo: purple
|
540 |
sdk: docker
|
541 |
+
app_port: 7860
|
542 |
pinned: false
|
543 |
+
license: mit
|
544 |
+
short_description: Modern restaurant management system with QR ordering
|
545 |
+
tags:
|
546 |
+
- restaurant
|
547 |
+
- management
|
548 |
+
- fastapi
|
549 |
+
- qr-code
|
550 |
+
- ordering
|
551 |
---
|
552 |
+
```
|
553 |
+
|
554 |
+
#### 4. Automatic Build Process
|
555 |
+
|
556 |
+
Hugging Face Spaces will automatically:
|
557 |
+
1. ✅ Pull your repository
|
558 |
+
2. ✅ Build Docker image using your Dockerfile
|
559 |
+
3. ✅ Initialize database with sample data
|
560 |
+
4. ✅ Start the application on port 7860
|
561 |
+
5. ✅ Provide public URL for access
|
562 |
+
|
563 |
+
### 📱 Using Your Deployed App
|
564 |
+
|
565 |
+
#### 📋 Access Points
|
566 |
+
|
567 |
+
| Interface | URL | Description |
|
568 |
+
|-----------|-----|-------------|
|
569 |
+
| **Home** | `/` | Main landing page with interface selection |
|
570 |
+
| **Customer** | `/customer` | QR code ordering interface |
|
571 |
+
| **Chef** | `/chef` | Kitchen order management |
|
572 |
+
| **Admin** | `/admin` | Complete restaurant management |
|
573 |
+
| **API Docs** | `/docs` | Interactive API documentation |
|
574 |
+
| **Health** | `/health` | System health check |
|
575 |
+
|
576 |
+
#### 🔑 Demo Credentials
|
577 |
+
|
578 |
+
- **Hotel Access Code**: `myhotel`
|
579 |
+
- **Demo Database**: `tabble_new.db`
|
580 |
+
- **Sample Tables**: 1-20
|
581 |
+
- **Phone OTP**: Any 6 digits (demo mode)
|
582 |
+
|
583 |
+
### 🔧 Customization for Production
|
584 |
+
|
585 |
+
#### 1. Hotel Configuration
|
586 |
+
|
587 |
+
Edit `hotels.csv`:
|
588 |
+
```csv
|
589 |
+
hotel_database,password
|
590 |
+
your_restaurant.db,secure_password_123
|
591 |
+
another_hotel.db,different_password_456
|
592 |
+
```
|
593 |
+
|
594 |
+
#### 2. Environment Variables
|
595 |
+
|
596 |
+
Create `.env` file:
|
597 |
+
```env
|
598 |
+
SECRET_KEY=your_production_secret_key
|
599 |
+
HUGGINGFACE_SPACES=1
|
600 |
+
PORT=7860
|
601 |
+
LOG_LEVEL=INFO
|
602 |
+
```
|
603 |
+
|
604 |
+
#### 3. Database Initialization
|
605 |
+
|
606 |
+
Create custom database:
|
607 |
+
```python
|
608 |
+
# Run this script to create your hotel database
|
609 |
+
python create_empty_db.py
|
610 |
+
```
|
611 |
+
|
612 |
+
#### 4. Menu Setup
|
613 |
+
|
614 |
+
1. Access admin panel: `your-space-url/admin`
|
615 |
+
2. Enter your hotel access code
|
616 |
+
3. Navigate to "Manage Dishes"
|
617 |
+
4. Add your menu items with images
|
618 |
+
5. Configure pricing and categories
|
619 |
+
|
620 |
+
### 📊 Monitoring and Analytics
|
621 |
+
|
622 |
+
#### Built-in Features
|
623 |
+
- ✅ Real-time order tracking
|
624 |
+
- ✅ Customer analytics dashboard
|
625 |
+
- ✅ Sales performance metrics
|
626 |
+
- ✅ Kitchen efficiency reports
|
627 |
+
- ✅ Revenue analytics
|
628 |
+
|
629 |
+
#### Health Monitoring
|
630 |
+
- **Health Check**: `your-space-url/health`
|
631 |
+
- **API Status**: Monitor via `/docs` endpoint
|
632 |
+
- **Database Status**: Check via admin panel
|
633 |
+
|
634 |
+
### 🚑 Troubleshooting
|
635 |
+
|
636 |
+
#### Common Issues
|
637 |
+
|
638 |
+
1. **Build Failures**:
|
639 |
+
```bash
|
640 |
+
# Check Dockerfile syntax
|
641 |
+
# Verify requirements.txt dependencies
|
642 |
+
# Ensure all required files are present
|
643 |
+
```
|
644 |
+
|
645 |
+
2. **Database Issues**:
|
646 |
+
```bash
|
647 |
+
# Check hotels.csv format
|
648 |
+
# Verify database permissions
|
649 |
+
# Run database initialization
|
650 |
+
```
|
651 |
+
|
652 |
+
3. **Template Loading**:
|
653 |
+
```bash
|
654 |
+
# Ensure templates/ directory exists
|
655 |
+
# Check template file paths
|
656 |
+
# Verify Jinja2 syntax
|
657 |
+
```
|
658 |
+
|
659 |
+
#### Getting Help
|
660 |
+
|
661 |
+
- **API Documentation**: `your-space-url/docs`
|
662 |
+
- **System Status**: `your-space-url/health`
|
663 |
+
- **Logs**: Check Hugging Face Spaces logs tab
|
664 |
+
- **Issues**: Create issue in GitHub repository
|
665 |
+
|
666 |
+
### 🔄 Updates and Maintenance
|
667 |
+
|
668 |
+
#### Updating Your Space
|
669 |
+
|
670 |
+
1. **Push changes** to your GitHub repository
|
671 |
+
2. **Hugging Face Spaces** will auto-rebuild
|
672 |
+
3. **Monitor build logs** in Spaces interface
|
673 |
+
4. **Test functionality** after deployment
|
674 |
+
|
675 |
+
#### Backup Strategies
|
676 |
+
|
677 |
+
1. **Database Backup**:
|
678 |
+
```python
|
679 |
+
# Use admin panel backup feature
|
680 |
+
# Or manually copy .db files
|
681 |
+
```
|
682 |
+
|
683 |
+
2. **Configuration Backup**:
|
684 |
+
```bash
|
685 |
+
# Backup hotels.csv
|
686 |
+
# Backup custom templates
|
687 |
+
# Backup uploaded images
|
688 |
+
```
|
689 |
+
|
690 |
+
### 🎆 Advanced Features
|
691 |
+
|
692 |
+
#### Multi-Language Support
|
693 |
+
- Modify templates for your language
|
694 |
+
- Update form labels and messages
|
695 |
+
- Configure number and currency formats
|
696 |
+
|
697 |
+
#### Custom Branding
|
698 |
+
- Upload your logo to static files
|
699 |
+
- Modify CSS in templates
|
700 |
+
- Update color schemes and fonts
|
701 |
+
|
702 |
+
#### Integration Options
|
703 |
+
- **Payment Gateways**: Add payment processing
|
704 |
+
- **SMS Services**: Integrate real OTP services
|
705 |
+
- **Analytics**: Connect to external analytics tools
|
706 |
+
- **POS Systems**: API integration capabilities
|
707 |
+
|
708 |
+
---
|
709 |
+
|
710 |
+
## 📞 Support and Community
|
711 |
+
|
712 |
+
- **Documentation**: Complete API docs at `/docs`
|
713 |
+
- **GitHub Issues**: Report bugs and request features
|
714 |
+
- **Community**: Join discussions in GitHub Discussions
|
715 |
+
- **Updates**: Follow repository for latest features
|
716 |
|
717 |
+
**Ready to revolutionize your restaurant management? Deploy on Hugging Face Spaces now!** 🚀🍽️
|
README_HUGGINGFACE.md
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
title: Tabble-v3 Restaurant Management System
|
2 |
+
emoji: 🍽️
|
3 |
+
colorFrom: blue
|
4 |
+
colorTo: purple
|
5 |
+
sdk: docker
|
6 |
+
app_port: 7860
|
7 |
+
pinned: false
|
8 |
+
license: mit
|
9 |
+
short_description: Modern restaurant management system with QR ordering and real-time analytics
|
10 |
+
tags:
|
11 |
+
- restaurant
|
12 |
+
- management
|
13 |
+
- fastapi
|
14 |
+
- qr-code
|
15 |
+
- ordering
|
16 |
+
- analytics
|
17 |
+
- multi-database
|
TABLE_MANAGEMENT_IMPLEMENTATION.md
ADDED
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Table Management Implementation
|
2 |
+
|
3 |
+
## Overview
|
4 |
+
|
5 |
+
This document describes the implementation of database-specific table occupancy management based on user navigation between home page and customer pages.
|
6 |
+
|
7 |
+
## Requirements Implemented
|
8 |
+
|
9 |
+
1. **Database Selection**: Users first select database and verify password from hotels.csv
|
10 |
+
2. **Table Occupancy Logic**:
|
11 |
+
- `is_occupied = 0` when user is on home page (table free)
|
12 |
+
- `is_occupied = 1` when user is on customer page (table occupied)
|
13 |
+
3. **Database Independence**: All table operations work within the selected database
|
14 |
+
4. **Hotel Manager Visibility**: Table status is for hotel manager visibility only
|
15 |
+
|
16 |
+
## Implementation Details
|
17 |
+
|
18 |
+
### Backend Changes
|
19 |
+
|
20 |
+
#### 1. New API Endpoint
|
21 |
+
- **Endpoint**: `PUT /tables/number/{table_number}/free`
|
22 |
+
- **Purpose**: Set table as free by table number
|
23 |
+
- **Location**: `app/routers/table.py`
|
24 |
+
|
25 |
+
```python
|
26 |
+
@router.put("/number/{table_number}/free", response_model=Table)
|
27 |
+
def set_table_free_by_number(table_number: int, db: Session = Depends(get_db)):
|
28 |
+
# Implementation details in the file
|
29 |
+
```
|
30 |
+
|
31 |
+
#### 2. Database Schema Update
|
32 |
+
- **Added Field**: `last_occupied_at` to tables table
|
33 |
+
- **Type**: `DATETIME`, nullable
|
34 |
+
- **Purpose**: Track when table was last occupied
|
35 |
+
- **Files Modified**:
|
36 |
+
- `app/database.py` (SQLAlchemy model)
|
37 |
+
- `app/models/table.py` (Pydantic models)
|
38 |
+
|
39 |
+
#### 3. Migration Support
|
40 |
+
- **Script**: `migrate_table_schema.py`
|
41 |
+
- **Purpose**: Add `last_occupied_at` column to existing databases
|
42 |
+
- **Usage**: Run before starting the application with existing databases
|
43 |
+
|
44 |
+
### Frontend Changes
|
45 |
+
|
46 |
+
#### 1. API Service Updates
|
47 |
+
- **File**: `frontend/src/services/api.js`
|
48 |
+
- **New Methods**:
|
49 |
+
- `customerService.setTableFreeByNumber(tableNumber)`
|
50 |
+
- `adminService.setTableFreeByNumber(tableNumber)`
|
51 |
+
|
52 |
+
#### 2. Home Page Updates
|
53 |
+
- **File**: `frontend/src/pages/Home.js`
|
54 |
+
- **Changes**:
|
55 |
+
- Added `freeTableOnHomeReturn()` function
|
56 |
+
- Automatically frees table when user returns to home page
|
57 |
+
- Uses selected database for table operations
|
58 |
+
|
59 |
+
#### 3. Customer Menu Updates
|
60 |
+
- **File**: `frontend/src/pages/customer/Menu.js`
|
61 |
+
- **Changes**:
|
62 |
+
- Enhanced back-to-home button to free table before navigation
|
63 |
+
- Added `beforeunload` event listener for browser close/refresh
|
64 |
+
- Uses `navigator.sendBeacon` for reliable cleanup
|
65 |
+
|
66 |
+
## Database Independence
|
67 |
+
|
68 |
+
### How It Works
|
69 |
+
1. **Database Selection**: Users select database on home page
|
70 |
+
2. **Session Management**: Database credentials stored in localStorage
|
71 |
+
3. **API Calls**: All table operations use the selected database
|
72 |
+
4. **Isolation**: Each database maintains its own table occupancy state
|
73 |
+
|
74 |
+
### Storage Keys
|
75 |
+
- `customerSelectedDatabase`: Selected database name
|
76 |
+
- `customerDatabasePassword`: Database password
|
77 |
+
- `tableNumber`: Current table number
|
78 |
+
|
79 |
+
## Table Occupancy Flow
|
80 |
+
|
81 |
+
### User Journey
|
82 |
+
1. **Home Page**: User selects database and table number
|
83 |
+
- Table status: `is_occupied = 0` (free)
|
84 |
+
2. **Navigate to Customer Page**: User enters customer interface
|
85 |
+
- Table status: `is_occupied = 1` (occupied)
|
86 |
+
- API call: `PUT /tables/number/{table_number}/occupy`
|
87 |
+
3. **Return to Home**: User clicks back button or navigates away
|
88 |
+
- Table status: `is_occupied = 0` (free)
|
89 |
+
- API call: `PUT /tables/number/{table_number}/free`
|
90 |
+
|
91 |
+
### Cleanup Scenarios
|
92 |
+
1. **Back Button**: Explicit table freeing before navigation
|
93 |
+
2. **Browser Close**: `beforeunload` event with `navigator.sendBeacon`
|
94 |
+
3. **Page Refresh**: `beforeunload` event with `navigator.sendBeacon`
|
95 |
+
4. **Direct Navigation**: Home page automatically frees table on load
|
96 |
+
|
97 |
+
## Testing
|
98 |
+
|
99 |
+
### Test Script
|
100 |
+
- **File**: `test_table_management.py`
|
101 |
+
- **Purpose**: Verify table management functionality
|
102 |
+
- **Tests**:
|
103 |
+
- Database selection
|
104 |
+
- Table creation
|
105 |
+
- Table occupation
|
106 |
+
- Table freeing
|
107 |
+
- Status retrieval
|
108 |
+
|
109 |
+
### Running Tests
|
110 |
+
```bash
|
111 |
+
python test_table_management.py
|
112 |
+
```
|
113 |
+
|
114 |
+
## Migration
|
115 |
+
|
116 |
+
### For Existing Databases
|
117 |
+
```bash
|
118 |
+
python migrate_table_schema.py
|
119 |
+
```
|
120 |
+
|
121 |
+
This will:
|
122 |
+
- Find all .db files in current directory
|
123 |
+
- Add `last_occupied_at` column if missing
|
124 |
+
- Preserve existing data
|
125 |
+
|
126 |
+
## API Endpoints Summary
|
127 |
+
|
128 |
+
| Method | Endpoint | Purpose |
|
129 |
+
|--------|----------|---------|
|
130 |
+
| PUT | `/tables/number/{table_number}/occupy` | Set table as occupied |
|
131 |
+
| PUT | `/tables/number/{table_number}/free` | Set table as free |
|
132 |
+
| GET | `/tables/status/summary` | Get table status summary |
|
133 |
+
| GET | `/tables/number/{table_number}` | Get table by number |
|
134 |
+
|
135 |
+
## Error Handling
|
136 |
+
|
137 |
+
- **Table Not Found**: Returns 404 error
|
138 |
+
- **Database Connection**: Graceful fallback and error logging
|
139 |
+
- **Network Issues**: Silent failure for cleanup operations
|
140 |
+
- **Invalid Table Number**: Validation and error messages
|
141 |
+
|
142 |
+
## Security Considerations
|
143 |
+
|
144 |
+
- **Database Access**: Password-protected database selection
|
145 |
+
- **Session Management**: Credentials stored in localStorage
|
146 |
+
- **API Security**: Database switching requires authentication
|
147 |
+
- **Data Isolation**: Complete separation between databases
|
148 |
+
|
149 |
+
## Performance Optimizations
|
150 |
+
|
151 |
+
- **Minimal API Calls**: Only when necessary
|
152 |
+
- **Async Operations**: Non-blocking table updates
|
153 |
+
- **Error Recovery**: Graceful handling of failed operations
|
154 |
+
- **Cleanup Efficiency**: `navigator.sendBeacon` for reliable cleanup
|
155 |
+
|
156 |
+
## Future Enhancements
|
157 |
+
|
158 |
+
1. **Real-time Updates**: WebSocket for live table status
|
159 |
+
2. **Table Reservations**: Advanced booking system
|
160 |
+
3. **Analytics**: Table utilization tracking
|
161 |
+
4. **Mobile Support**: Touch-optimized interface
|
162 |
+
5. **Multi-language**: Internationalization support
|
app.py
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python3
|
2 |
+
"""
|
3 |
+
Tabble-v3 Restaurant Management System
|
4 |
+
Entry point for Hugging Face Spaces deployment
|
5 |
+
"""
|
6 |
+
|
7 |
+
import uvicorn
|
8 |
+
import os
|
9 |
+
import sys
|
10 |
+
from pathlib import Path
|
11 |
+
|
12 |
+
# Add the app directory to the Python path
|
13 |
+
app_dir = Path(__file__).parent / "app"
|
14 |
+
sys.path.insert(0, str(app_dir))
|
15 |
+
|
16 |
+
def main():
|
17 |
+
"""Main entry point for the application"""
|
18 |
+
|
19 |
+
# Create static directories if they don't exist
|
20 |
+
os.makedirs("app/static/images/dishes", exist_ok=True)
|
21 |
+
os.makedirs("templates", exist_ok=True)
|
22 |
+
|
23 |
+
# Set environment variables for Hugging Face Spaces
|
24 |
+
os.environ.setdefault("HUGGINGFACE_SPACES", "1")
|
25 |
+
|
26 |
+
# Get port from environment (Hugging Face Spaces uses 7860)
|
27 |
+
port = int(os.environ.get("PORT", 7860))
|
28 |
+
host = os.environ.get("HOST", "0.0.0.0")
|
29 |
+
|
30 |
+
print(f"🚀 Starting Tabble-v3 Restaurant Management System")
|
31 |
+
print(f"📡 Server: http://{host}:{port}")
|
32 |
+
print(f"📖 API Documentation: http://{host}:{port}/docs")
|
33 |
+
print(f"🏥 Health Check: http://{host}:{port}/health")
|
34 |
+
print("=" * 60)
|
35 |
+
|
36 |
+
# Start the FastAPI application
|
37 |
+
uvicorn.run(
|
38 |
+
"app.main:app",
|
39 |
+
host=host,
|
40 |
+
port=port,
|
41 |
+
reload=False, # Disable reload in production
|
42 |
+
log_level="info",
|
43 |
+
access_log=True
|
44 |
+
)
|
45 |
+
|
46 |
+
if __name__ == "__main__":
|
47 |
+
main()
|
app/__init__.py
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Tabble-v3 Restaurant Management System
|
3 |
+
Main application package
|
4 |
+
"""
|
5 |
+
|
6 |
+
__version__ = "3.1.0"
|
7 |
+
__author__ = "Tabble Development Team"
|
8 |
+
__description__ = "Modern restaurant management system with QR ordering and real-time analytics"
|
app/database.py
ADDED
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import (
|
2 |
+
create_engine,
|
3 |
+
Column,
|
4 |
+
Integer,
|
5 |
+
String,
|
6 |
+
Float,
|
7 |
+
ForeignKey,
|
8 |
+
DateTime,
|
9 |
+
Text,
|
10 |
+
Boolean,
|
11 |
+
UniqueConstraint,
|
12 |
+
)
|
13 |
+
from sqlalchemy.ext.declarative import declarative_base
|
14 |
+
from sqlalchemy.orm import sessionmaker, relationship, scoped_session
|
15 |
+
from datetime import datetime, timezone
|
16 |
+
import os
|
17 |
+
import threading
|
18 |
+
from typing import Dict, Optional
|
19 |
+
import uuid
|
20 |
+
|
21 |
+
# Base declarative class
|
22 |
+
Base = declarative_base()
|
23 |
+
|
24 |
+
# Session-based database manager with hotel context
|
25 |
+
class DatabaseManager:
|
26 |
+
def __init__(self):
|
27 |
+
self.sessions: Dict[str, dict] = {}
|
28 |
+
self.lock = threading.Lock()
|
29 |
+
self.unified_database = "Tabble.db"
|
30 |
+
|
31 |
+
def get_session_id(self, request_headers: dict) -> str:
|
32 |
+
"""Generate or retrieve session ID from request headers"""
|
33 |
+
session_id = request_headers.get('x-session-id')
|
34 |
+
if not session_id:
|
35 |
+
session_id = str(uuid.uuid4())
|
36 |
+
return session_id
|
37 |
+
|
38 |
+
def get_database_connection(self, session_id: str, hotel_id: Optional[int] = None) -> dict:
|
39 |
+
"""Get or create database connection for session with hotel context"""
|
40 |
+
with self.lock:
|
41 |
+
if session_id not in self.sessions:
|
42 |
+
# Create new session with unified database
|
43 |
+
self.sessions[session_id] = self._create_connection(hotel_id)
|
44 |
+
elif hotel_id and self.sessions[session_id].get('hotel_id') != hotel_id:
|
45 |
+
# Update hotel context for existing session
|
46 |
+
self.sessions[session_id]['hotel_id'] = hotel_id
|
47 |
+
|
48 |
+
return self.sessions[session_id]
|
49 |
+
|
50 |
+
def _create_connection(self, hotel_id: Optional[int] = None) -> dict:
|
51 |
+
"""Create a new database connection to unified database"""
|
52 |
+
database_url = f"sqlite:///./Tabble.db"
|
53 |
+
engine = create_engine(database_url, connect_args={"check_same_thread": False})
|
54 |
+
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
55 |
+
session_local = scoped_session(session_factory)
|
56 |
+
|
57 |
+
# Create tables in the database if they don't exist
|
58 |
+
Base.metadata.create_all(bind=engine)
|
59 |
+
|
60 |
+
return {
|
61 |
+
'database_name': self.unified_database,
|
62 |
+
'database_url': database_url,
|
63 |
+
'engine': engine,
|
64 |
+
'session_local': session_local,
|
65 |
+
'hotel_id': hotel_id
|
66 |
+
}
|
67 |
+
|
68 |
+
def _dispose_connection(self, session_id: str):
|
69 |
+
"""Dispose of database connection for session"""
|
70 |
+
if session_id in self.sessions:
|
71 |
+
connection = self.sessions[session_id]
|
72 |
+
connection['session_local'].remove()
|
73 |
+
connection['engine'].dispose()
|
74 |
+
|
75 |
+
def set_hotel_context(self, session_id: str, hotel_id: int) -> bool:
|
76 |
+
"""Set hotel context for a specific session"""
|
77 |
+
try:
|
78 |
+
self.get_database_connection(session_id, hotel_id)
|
79 |
+
print(f"Session {session_id} set to hotel_id: {hotel_id}")
|
80 |
+
return True
|
81 |
+
except Exception as e:
|
82 |
+
print(f"Error setting hotel context for session {session_id}: {e}")
|
83 |
+
return False
|
84 |
+
|
85 |
+
def get_current_hotel_id(self, session_id: str) -> Optional[int]:
|
86 |
+
"""Get current hotel_id for session"""
|
87 |
+
if session_id in self.sessions:
|
88 |
+
return self.sessions[session_id].get('hotel_id')
|
89 |
+
return None
|
90 |
+
|
91 |
+
def get_current_database(self, session_id: str) -> str:
|
92 |
+
"""Get current database name for session (always Tabble.db)"""
|
93 |
+
return self.unified_database
|
94 |
+
|
95 |
+
def authenticate_hotel(self, hotel_name: str, password: str) -> Optional[int]:
|
96 |
+
"""Authenticate hotel and return hotel_id"""
|
97 |
+
try:
|
98 |
+
# Use global engine to query hotels table
|
99 |
+
from sqlalchemy.orm import sessionmaker
|
100 |
+
Session = sessionmaker(bind=engine)
|
101 |
+
db = Session()
|
102 |
+
|
103 |
+
hotel = db.query(Hotel).filter(
|
104 |
+
Hotel.hotel_name == hotel_name,
|
105 |
+
Hotel.password == password
|
106 |
+
).first()
|
107 |
+
|
108 |
+
db.close()
|
109 |
+
|
110 |
+
if hotel:
|
111 |
+
return hotel.id
|
112 |
+
return None
|
113 |
+
except Exception as e:
|
114 |
+
print(f"Error authenticating hotel {hotel_name}: {e}")
|
115 |
+
return None
|
116 |
+
|
117 |
+
def cleanup_session(self, session_id: str):
|
118 |
+
"""Clean up session resources"""
|
119 |
+
with self.lock:
|
120 |
+
if session_id in self.sessions:
|
121 |
+
self._dispose_connection(session_id)
|
122 |
+
del self.sessions[session_id]
|
123 |
+
|
124 |
+
# Global database manager instance
|
125 |
+
db_manager = DatabaseManager()
|
126 |
+
|
127 |
+
# Global variables for database connection (unified database)
|
128 |
+
CURRENT_DATABASE = "Tabble.db"
|
129 |
+
DATABASE_URL = f"sqlite:///./Tabble.db" # Using the unified database
|
130 |
+
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
131 |
+
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
132 |
+
SessionLocal = scoped_session(session_factory)
|
133 |
+
|
134 |
+
# Lock for thread safety when switching databases
|
135 |
+
db_lock = threading.Lock()
|
136 |
+
|
137 |
+
|
138 |
+
# Database models
|
139 |
+
class Hotel(Base):
|
140 |
+
__tablename__ = "hotels"
|
141 |
+
|
142 |
+
id = Column(Integer, primary_key=True, index=True)
|
143 |
+
hotel_name = Column(String, unique=True, index=True, nullable=False)
|
144 |
+
password = Column(String, nullable=False)
|
145 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
146 |
+
updated_at = Column(
|
147 |
+
DateTime,
|
148 |
+
default=lambda: datetime.now(timezone.utc),
|
149 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
150 |
+
)
|
151 |
+
|
152 |
+
# Relationships
|
153 |
+
dishes = relationship("Dish", back_populates="hotel")
|
154 |
+
persons = relationship("Person", back_populates="hotel")
|
155 |
+
orders = relationship("Order", back_populates="hotel")
|
156 |
+
tables = relationship("Table", back_populates="hotel")
|
157 |
+
settings = relationship("Settings", back_populates="hotel")
|
158 |
+
feedback = relationship("Feedback", back_populates="hotel")
|
159 |
+
loyalty_tiers = relationship("LoyaltyProgram", back_populates="hotel")
|
160 |
+
selection_offers = relationship("SelectionOffer", back_populates="hotel")
|
161 |
+
|
162 |
+
|
163 |
+
class Dish(Base):
|
164 |
+
__tablename__ = "dishes"
|
165 |
+
|
166 |
+
id = Column(Integer, primary_key=True, index=True)
|
167 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
168 |
+
name = Column(String, index=True)
|
169 |
+
description = Column(Text, nullable=True)
|
170 |
+
category = Column(String, index=True) # Now stores JSON array for multiple categories
|
171 |
+
price = Column(Float)
|
172 |
+
quantity = Column(Integer, default=0) # Made optional in forms, but keeps default
|
173 |
+
image_path = Column(String, nullable=True)
|
174 |
+
discount = Column(Float, default=0) # Discount amount (percentage)
|
175 |
+
is_offer = Column(Integer, default=0) # 0 = not an offer, 1 = is an offer
|
176 |
+
is_special = Column(Integer, default=0) # 0 = not special, 1 = today's special
|
177 |
+
is_vegetarian = Column(Integer, default=1) # 1 = vegetarian, 0 = non-vegetarian
|
178 |
+
visibility = Column(Integer, default=1) # 1 = visible, 0 = hidden (soft delete)
|
179 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
180 |
+
updated_at = Column(
|
181 |
+
DateTime,
|
182 |
+
default=lambda: datetime.now(timezone.utc),
|
183 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
184 |
+
)
|
185 |
+
|
186 |
+
# Relationships
|
187 |
+
hotel = relationship("Hotel", back_populates="dishes")
|
188 |
+
|
189 |
+
# Relationship with OrderItem
|
190 |
+
order_items = relationship("OrderItem", back_populates="dish")
|
191 |
+
|
192 |
+
|
193 |
+
class Order(Base):
|
194 |
+
__tablename__ = "orders"
|
195 |
+
|
196 |
+
id = Column(Integer, primary_key=True, index=True)
|
197 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
198 |
+
table_number = Column(Integer)
|
199 |
+
unique_id = Column(String, index=True)
|
200 |
+
person_id = Column(Integer, ForeignKey("persons.id"), nullable=True)
|
201 |
+
status = Column(String, default="pending") # pending, accepted, completed, paid
|
202 |
+
total_amount = Column(Float, nullable=True) # Final amount paid after all discounts
|
203 |
+
subtotal_amount = Column(Float, nullable=True) # Original amount before discounts
|
204 |
+
loyalty_discount_amount = Column(Float, default=0) # Loyalty discount applied
|
205 |
+
selection_offer_discount_amount = Column(Float, default=0) # Selection offer discount applied
|
206 |
+
loyalty_discount_percentage = Column(Float, default=0) # Loyalty discount percentage applied
|
207 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
208 |
+
updated_at = Column(
|
209 |
+
DateTime,
|
210 |
+
default=lambda: datetime.now(timezone.utc),
|
211 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
212 |
+
)
|
213 |
+
|
214 |
+
# Relationships
|
215 |
+
hotel = relationship("Hotel", back_populates="orders")
|
216 |
+
items = relationship("OrderItem", back_populates="order")
|
217 |
+
person = relationship("Person", back_populates="orders")
|
218 |
+
|
219 |
+
|
220 |
+
class Person(Base):
|
221 |
+
__tablename__ = "persons"
|
222 |
+
|
223 |
+
id = Column(Integer, primary_key=True, index=True)
|
224 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
225 |
+
username = Column(String, index=True)
|
226 |
+
password = Column(String)
|
227 |
+
phone_number = Column(String, index=True, nullable=True) # Added phone number field
|
228 |
+
visit_count = Column(Integer, default=0)
|
229 |
+
last_visit = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
230 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
231 |
+
|
232 |
+
# Unique constraints per hotel
|
233 |
+
__table_args__ = (
|
234 |
+
UniqueConstraint('hotel_id', 'username', name='uq_person_hotel_username'),
|
235 |
+
UniqueConstraint('hotel_id', 'phone_number', name='uq_person_hotel_phone'),
|
236 |
+
)
|
237 |
+
|
238 |
+
# Relationships
|
239 |
+
hotel = relationship("Hotel", back_populates="persons")
|
240 |
+
orders = relationship("Order", back_populates="person")
|
241 |
+
|
242 |
+
|
243 |
+
class OrderItem(Base):
|
244 |
+
__tablename__ = "order_items"
|
245 |
+
|
246 |
+
id = Column(Integer, primary_key=True, index=True)
|
247 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
248 |
+
order_id = Column(Integer, ForeignKey("orders.id"))
|
249 |
+
dish_id = Column(Integer, ForeignKey("dishes.id"))
|
250 |
+
quantity = Column(Integer, default=1)
|
251 |
+
price = Column(Float, nullable=True) # Price at time of order
|
252 |
+
remarks = Column(Text, nullable=True)
|
253 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
254 |
+
|
255 |
+
# Relationships
|
256 |
+
order = relationship("Order", back_populates="items")
|
257 |
+
dish = relationship("Dish", back_populates="order_items")
|
258 |
+
|
259 |
+
|
260 |
+
class Feedback(Base):
|
261 |
+
__tablename__ = "feedback"
|
262 |
+
|
263 |
+
id = Column(Integer, primary_key=True, index=True)
|
264 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
265 |
+
order_id = Column(Integer, ForeignKey("orders.id"))
|
266 |
+
person_id = Column(Integer, ForeignKey("persons.id"), nullable=True)
|
267 |
+
rating = Column(Integer) # 1-5 stars
|
268 |
+
comment = Column(Text, nullable=True)
|
269 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
270 |
+
|
271 |
+
# Relationships
|
272 |
+
hotel = relationship("Hotel", back_populates="feedback")
|
273 |
+
order = relationship("Order")
|
274 |
+
person = relationship("Person")
|
275 |
+
|
276 |
+
|
277 |
+
class LoyaltyProgram(Base):
|
278 |
+
__tablename__ = "loyalty_tiers"
|
279 |
+
|
280 |
+
id = Column(Integer, primary_key=True, index=True)
|
281 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
282 |
+
visit_count = Column(Integer) # Number of visits required
|
283 |
+
discount_percentage = Column(Float) # Discount percentage
|
284 |
+
is_active = Column(Boolean, default=True) # Whether this loyalty tier is active
|
285 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
286 |
+
updated_at = Column(
|
287 |
+
DateTime,
|
288 |
+
default=lambda: datetime.now(timezone.utc),
|
289 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
290 |
+
)
|
291 |
+
|
292 |
+
# Unique constraint per hotel
|
293 |
+
__table_args__ = (
|
294 |
+
UniqueConstraint('hotel_id', 'visit_count', name='uq_loyalty_hotel_visits'),
|
295 |
+
)
|
296 |
+
|
297 |
+
# Relationships
|
298 |
+
hotel = relationship("Hotel", back_populates="loyalty_tiers")
|
299 |
+
|
300 |
+
|
301 |
+
class SelectionOffer(Base):
|
302 |
+
__tablename__ = "selection_offers"
|
303 |
+
|
304 |
+
id = Column(Integer, primary_key=True, index=True)
|
305 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
306 |
+
min_amount = Column(Float) # Minimum order amount to qualify
|
307 |
+
discount_amount = Column(Float) # Fixed discount amount to apply
|
308 |
+
is_active = Column(Boolean, default=True) # Whether this offer is active
|
309 |
+
description = Column(String, nullable=True) # Optional description of the offer
|
310 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
311 |
+
updated_at = Column(
|
312 |
+
DateTime,
|
313 |
+
default=lambda: datetime.now(timezone.utc),
|
314 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
315 |
+
)
|
316 |
+
|
317 |
+
# Relationships
|
318 |
+
hotel = relationship("Hotel", back_populates="selection_offers")
|
319 |
+
|
320 |
+
|
321 |
+
class Table(Base):
|
322 |
+
__tablename__ = "tables"
|
323 |
+
|
324 |
+
id = Column(Integer, primary_key=True, index=True)
|
325 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
326 |
+
table_number = Column(Integer) # Table number
|
327 |
+
is_occupied = Column(
|
328 |
+
Boolean, default=False
|
329 |
+
) # Whether the table is currently occupied
|
330 |
+
current_order_id = Column(
|
331 |
+
Integer, ForeignKey("orders.id"), nullable=True
|
332 |
+
) # Current active order
|
333 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
334 |
+
updated_at = Column(
|
335 |
+
DateTime,
|
336 |
+
default=lambda: datetime.now(timezone.utc),
|
337 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
338 |
+
)
|
339 |
+
|
340 |
+
# Unique constraint per hotel
|
341 |
+
__table_args__ = (
|
342 |
+
UniqueConstraint('hotel_id', 'table_number', name='uq_table_hotel_number'),
|
343 |
+
)
|
344 |
+
|
345 |
+
# Relationships
|
346 |
+
hotel = relationship("Hotel", back_populates="tables")
|
347 |
+
current_order = relationship("Order", foreign_keys=[current_order_id])
|
348 |
+
|
349 |
+
|
350 |
+
class Settings(Base):
|
351 |
+
__tablename__ = "settings"
|
352 |
+
|
353 |
+
id = Column(Integer, primary_key=True, index=True)
|
354 |
+
hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True)
|
355 |
+
hotel_name = Column(String, nullable=False, default="Tabble Hotel")
|
356 |
+
address = Column(String, nullable=True)
|
357 |
+
contact_number = Column(String, nullable=True)
|
358 |
+
email = Column(String, nullable=True)
|
359 |
+
tax_id = Column(String, nullable=True)
|
360 |
+
logo_path = Column(String, nullable=True)
|
361 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
362 |
+
updated_at = Column(
|
363 |
+
DateTime,
|
364 |
+
default=lambda: datetime.now(timezone.utc),
|
365 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
366 |
+
)
|
367 |
+
|
368 |
+
# Unique constraint per hotel
|
369 |
+
__table_args__ = (
|
370 |
+
UniqueConstraint('hotel_id', name='uq_settings_hotel'),
|
371 |
+
)
|
372 |
+
|
373 |
+
# Relationships
|
374 |
+
hotel = relationship("Hotel", back_populates="settings")
|
375 |
+
|
376 |
+
|
377 |
+
# Function to switch database
|
378 |
+
def switch_database(database_name):
|
379 |
+
global CURRENT_DATABASE, DATABASE_URL, engine, SessionLocal
|
380 |
+
|
381 |
+
with db_lock:
|
382 |
+
if database_name == CURRENT_DATABASE:
|
383 |
+
return # Already using this database
|
384 |
+
|
385 |
+
# Update global variables
|
386 |
+
CURRENT_DATABASE = database_name
|
387 |
+
DATABASE_URL = f"sqlite:///./tabble_new.db" if database_name == "tabble_new.db" else f"sqlite:///./{database_name}"
|
388 |
+
|
389 |
+
# Dispose of the old engine and create a new one
|
390 |
+
engine.dispose()
|
391 |
+
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
392 |
+
|
393 |
+
# Create a new session factory and scoped session
|
394 |
+
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
395 |
+
SessionLocal.remove()
|
396 |
+
SessionLocal = scoped_session(session_factory)
|
397 |
+
|
398 |
+
# Create tables in the new database if they don't exist
|
399 |
+
create_tables()
|
400 |
+
|
401 |
+
print(f"Switched to database: {database_name}")
|
402 |
+
|
403 |
+
|
404 |
+
# Get current database name
|
405 |
+
def get_current_database():
|
406 |
+
return CURRENT_DATABASE
|
407 |
+
|
408 |
+
|
409 |
+
# Create tables
|
410 |
+
def create_tables():
|
411 |
+
# Create all tables (only creates tables that don't exist)
|
412 |
+
Base.metadata.create_all(bind=engine)
|
413 |
+
print("Database tables created/verified successfully")
|
414 |
+
|
415 |
+
|
416 |
+
# Get database session (legacy)
|
417 |
+
def get_db():
|
418 |
+
db = SessionLocal()
|
419 |
+
try:
|
420 |
+
yield db
|
421 |
+
finally:
|
422 |
+
db.close()
|
423 |
+
|
424 |
+
|
425 |
+
# Session-aware database functions with hotel context
|
426 |
+
def get_session_db(session_id: str, hotel_id: Optional[int] = None):
|
427 |
+
"""Get database session for a specific session ID with hotel context"""
|
428 |
+
connection = db_manager.get_database_connection(session_id, hotel_id)
|
429 |
+
db = connection['session_local']()
|
430 |
+
try:
|
431 |
+
yield db
|
432 |
+
finally:
|
433 |
+
db.close()
|
434 |
+
|
435 |
+
|
436 |
+
def set_session_hotel_context(session_id: str, hotel_id: int) -> bool:
|
437 |
+
"""Set hotel context for a specific session"""
|
438 |
+
return db_manager.set_hotel_context(session_id, hotel_id)
|
439 |
+
|
440 |
+
|
441 |
+
def get_session_hotel_id(session_id: str) -> Optional[int]:
|
442 |
+
"""Get current hotel_id for a session"""
|
443 |
+
return db_manager.get_current_hotel_id(session_id)
|
444 |
+
|
445 |
+
|
446 |
+
def get_session_current_database(session_id: str) -> str:
|
447 |
+
"""Get current database name for a session (always Tabble.db)"""
|
448 |
+
return db_manager.get_current_database(session_id)
|
449 |
+
|
450 |
+
|
451 |
+
def authenticate_hotel_session(hotel_name: str, password: str) -> Optional[int]:
|
452 |
+
"""Authenticate hotel and return hotel_id"""
|
453 |
+
return db_manager.authenticate_hotel(hotel_name, password)
|
454 |
+
|
455 |
+
|
456 |
+
def cleanup_session_db(session_id: str):
|
457 |
+
"""Clean up database resources for a session"""
|
458 |
+
db_manager.cleanup_session(session_id)
|
459 |
+
|
460 |
+
|
461 |
+
# Helper function to get hotel_id from request
|
462 |
+
def get_hotel_id_from_request(request) -> int:
|
463 |
+
"""Get hotel_id from request session, raise HTTPException if not found"""
|
464 |
+
from fastapi import HTTPException
|
465 |
+
from .middleware import get_session_id
|
466 |
+
|
467 |
+
session_id = get_session_id(request)
|
468 |
+
hotel_id = get_session_hotel_id(session_id)
|
469 |
+
|
470 |
+
if not hotel_id:
|
471 |
+
raise HTTPException(status_code=400, detail="No hotel context set")
|
472 |
+
|
473 |
+
return hotel_id
|
app/main.py
ADDED
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI, Request, Depends
|
2 |
+
from fastapi.staticfiles import StaticFiles
|
3 |
+
from fastapi.templating import Jinja2Templates
|
4 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
|
5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
6 |
+
from sqlalchemy.orm import Session
|
7 |
+
import uvicorn
|
8 |
+
import os
|
9 |
+
|
10 |
+
from .database import get_db, create_tables
|
11 |
+
from .routers import chef, customer, admin, feedback, loyalty, selection_offer, table, analytics, settings
|
12 |
+
from .middleware import SessionMiddleware
|
13 |
+
|
14 |
+
# Create FastAPI app
|
15 |
+
app = FastAPI(title="Tabble - Hotel Management App")
|
16 |
+
|
17 |
+
# Add CORS middleware to allow cross-origin requests
|
18 |
+
app.add_middleware(
|
19 |
+
CORSMiddleware,
|
20 |
+
allow_origins=["*"], # Allow all origins
|
21 |
+
allow_credentials=True,
|
22 |
+
allow_methods=["*"], # Allow all methods
|
23 |
+
allow_headers=["*"], # Allow all headers
|
24 |
+
)
|
25 |
+
|
26 |
+
# Add session middleware for database management
|
27 |
+
app.add_middleware(SessionMiddleware, require_database=True)
|
28 |
+
|
29 |
+
# Mount static files
|
30 |
+
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
31 |
+
|
32 |
+
# Setup templates
|
33 |
+
templates = Jinja2Templates(directory="templates")
|
34 |
+
|
35 |
+
# Include routers
|
36 |
+
app.include_router(chef.router)
|
37 |
+
app.include_router(customer.router)
|
38 |
+
app.include_router(admin.router)
|
39 |
+
app.include_router(feedback.router)
|
40 |
+
app.include_router(loyalty.router)
|
41 |
+
app.include_router(selection_offer.router)
|
42 |
+
app.include_router(table.router)
|
43 |
+
app.include_router(analytics.router)
|
44 |
+
app.include_router(settings.router)
|
45 |
+
|
46 |
+
# Create database tables
|
47 |
+
create_tables()
|
48 |
+
|
49 |
+
# Check if we have the React build folder
|
50 |
+
react_build_dir = "frontend/build"
|
51 |
+
has_react_build = os.path.isdir(react_build_dir)
|
52 |
+
|
53 |
+
if has_react_build:
|
54 |
+
# Mount the React build folder
|
55 |
+
app.mount("/", StaticFiles(directory=react_build_dir, html=True), name="react")
|
56 |
+
|
57 |
+
|
58 |
+
# Health check endpoint for Render
|
59 |
+
@app.get("/health")
|
60 |
+
async def health_check():
|
61 |
+
return {"status": "healthy", "message": "Tabble API is running"}
|
62 |
+
|
63 |
+
|
64 |
+
# Root route - serve React app in production, otherwise serve index.html template
|
65 |
+
@app.get("/", response_class=HTMLResponse)
|
66 |
+
async def root(request: Request):
|
67 |
+
if has_react_build:
|
68 |
+
return FileResponse(f"{react_build_dir}/index.html")
|
69 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
70 |
+
|
71 |
+
|
72 |
+
# Chef page
|
73 |
+
@app.get("/chef", response_class=HTMLResponse)
|
74 |
+
async def chef_page(request: Request):
|
75 |
+
return templates.TemplateResponse("chef/index.html", {"request": request})
|
76 |
+
|
77 |
+
|
78 |
+
# Chef orders page
|
79 |
+
@app.get("/chef/orders", response_class=HTMLResponse)
|
80 |
+
async def chef_orders_page(request: Request):
|
81 |
+
return templates.TemplateResponse("chef/orders.html", {"request": request})
|
82 |
+
|
83 |
+
|
84 |
+
# Customer login page
|
85 |
+
@app.get("/customer", response_class=HTMLResponse)
|
86 |
+
async def customer_login_page(request: Request):
|
87 |
+
return templates.TemplateResponse("customer/login.html", {"request": request})
|
88 |
+
|
89 |
+
|
90 |
+
# Customer menu page
|
91 |
+
@app.get("/customer/menu", response_class=HTMLResponse)
|
92 |
+
async def customer_menu_page(request: Request, table_number: int, unique_id: str):
|
93 |
+
return templates.TemplateResponse(
|
94 |
+
"customer/menu.html",
|
95 |
+
{"request": request, "table_number": table_number, "unique_id": unique_id},
|
96 |
+
)
|
97 |
+
|
98 |
+
|
99 |
+
# Admin page
|
100 |
+
@app.get("/admin", response_class=HTMLResponse)
|
101 |
+
async def admin_page(request: Request):
|
102 |
+
return templates.TemplateResponse("admin/index.html", {"request": request})
|
103 |
+
|
104 |
+
|
105 |
+
# Admin dishes page
|
106 |
+
@app.get("/admin/dishes", response_class=HTMLResponse)
|
107 |
+
async def admin_dishes_page(request: Request):
|
108 |
+
return templates.TemplateResponse("admin/dishes.html", {"request": request})
|
109 |
+
|
110 |
+
|
111 |
+
|
112 |
+
|
113 |
+
|
114 |
+
if __name__ == "__main__":
|
115 |
+
PORT = os.getenv("PORT", 8000)
|
116 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=PORT, reload=True)
|
app/middleware/__init__.py
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
from .session_middleware import SessionMiddleware, get_session_id
|
2 |
+
|
3 |
+
__all__ = ['SessionMiddleware', 'get_session_id']
|
app/middleware/session_middleware.py
ADDED
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import Request, Response
|
2 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
3 |
+
from starlette.responses import JSONResponse
|
4 |
+
import uuid
|
5 |
+
from typing import Callable
|
6 |
+
from ..database import db_manager
|
7 |
+
|
8 |
+
|
9 |
+
class SessionMiddleware(BaseHTTPMiddleware):
|
10 |
+
"""Middleware to handle session-based database management"""
|
11 |
+
|
12 |
+
def __init__(self, app, require_database: bool = True):
|
13 |
+
super().__init__(app)
|
14 |
+
self.require_database = require_database
|
15 |
+
|
16 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
17 |
+
# Skip validation for OPTIONS requests (CORS preflight)
|
18 |
+
if request.method == "OPTIONS":
|
19 |
+
response = await call_next(request)
|
20 |
+
return response
|
21 |
+
|
22 |
+
# Get or generate session ID
|
23 |
+
session_id = request.headers.get('x-session-id')
|
24 |
+
if not session_id:
|
25 |
+
session_id = str(uuid.uuid4())
|
26 |
+
|
27 |
+
# Add session ID to request state
|
28 |
+
request.state.session_id = session_id
|
29 |
+
|
30 |
+
# Check if this is a database-related endpoint
|
31 |
+
path = request.url.path
|
32 |
+
is_database_endpoint = (
|
33 |
+
path.startswith('/settings/') or
|
34 |
+
path.startswith('/customer/api/') or
|
35 |
+
path.startswith('/chef/') or
|
36 |
+
path.startswith('/admin/') or
|
37 |
+
path.startswith('/analytics/') or
|
38 |
+
path.startswith('/tables/') or
|
39 |
+
path.startswith('/feedback/') or
|
40 |
+
path.startswith('/loyalty/') or
|
41 |
+
path.startswith('/selection-offers/')
|
42 |
+
)
|
43 |
+
|
44 |
+
# Skip session validation for certain endpoints
|
45 |
+
skip_validation_endpoints = [
|
46 |
+
'/settings/databases',
|
47 |
+
'/settings/hotels',
|
48 |
+
'/settings/switch-database',
|
49 |
+
'/settings/switch-hotel',
|
50 |
+
'/settings/current-database',
|
51 |
+
'/settings/current-hotel'
|
52 |
+
]
|
53 |
+
|
54 |
+
# Skip validation for admin and chef routes - they handle their own database selection
|
55 |
+
skip_validation_paths = [
|
56 |
+
'/admin/',
|
57 |
+
'/chef/'
|
58 |
+
]
|
59 |
+
|
60 |
+
# Check if path should skip validation
|
61 |
+
should_skip_path = any(path.startswith(skip_path) for skip_path in skip_validation_paths)
|
62 |
+
|
63 |
+
should_validate = (
|
64 |
+
is_database_endpoint and
|
65 |
+
path not in skip_validation_endpoints and
|
66 |
+
not should_skip_path and
|
67 |
+
self.require_database
|
68 |
+
)
|
69 |
+
|
70 |
+
if should_validate:
|
71 |
+
# Check if session has a valid hotel context
|
72 |
+
current_hotel_id = db_manager.get_current_hotel_id(session_id)
|
73 |
+
if not current_hotel_id:
|
74 |
+
# Check if there's stored hotel credentials in headers
|
75 |
+
stored_hotel_name = request.headers.get('x-hotel-name')
|
76 |
+
stored_password = request.headers.get('x-hotel-password')
|
77 |
+
|
78 |
+
if stored_hotel_name and stored_password:
|
79 |
+
# Try to verify and set hotel context
|
80 |
+
try:
|
81 |
+
# Authenticate hotel using the database manager
|
82 |
+
hotel_id = db_manager.authenticate_hotel(stored_hotel_name, stored_password)
|
83 |
+
|
84 |
+
if hotel_id:
|
85 |
+
# Valid credentials, set hotel context
|
86 |
+
db_manager.set_hotel_context(session_id, hotel_id)
|
87 |
+
else:
|
88 |
+
# Invalid credentials
|
89 |
+
return JSONResponse(
|
90 |
+
status_code=401,
|
91 |
+
content={
|
92 |
+
"detail": "Invalid hotel credentials",
|
93 |
+
"error_code": "HOTEL_AUTH_FAILED"
|
94 |
+
}
|
95 |
+
)
|
96 |
+
except Exception as e:
|
97 |
+
return JSONResponse(
|
98 |
+
status_code=500,
|
99 |
+
content={
|
100 |
+
"detail": f"Hotel authentication failed: {str(e)}",
|
101 |
+
"error_code": "HOTEL_VERIFICATION_ERROR"
|
102 |
+
}
|
103 |
+
)
|
104 |
+
else:
|
105 |
+
# No hotel selected
|
106 |
+
return JSONResponse(
|
107 |
+
status_code=400,
|
108 |
+
content={
|
109 |
+
"detail": "No hotel selected. Please select a hotel first.",
|
110 |
+
"error_code": "HOTEL_NOT_SELECTED"
|
111 |
+
}
|
112 |
+
)
|
113 |
+
|
114 |
+
# Process the request
|
115 |
+
response = await call_next(request)
|
116 |
+
|
117 |
+
# Add session ID to response headers
|
118 |
+
response.headers["x-session-id"] = session_id
|
119 |
+
|
120 |
+
return response
|
121 |
+
|
122 |
+
|
123 |
+
def get_session_id(request: Request) -> str:
|
124 |
+
"""Helper function to get session ID from request"""
|
125 |
+
return getattr(request.state, 'session_id', str(uuid.uuid4()))
|
app/models/database_config.py
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import List, Optional
|
3 |
+
|
4 |
+
|
5 |
+
class DatabaseEntry(BaseModel):
|
6 |
+
database_name: str
|
7 |
+
password: str
|
8 |
+
|
9 |
+
|
10 |
+
class DatabaseList(BaseModel):
|
11 |
+
databases: List[DatabaseEntry]
|
12 |
+
|
13 |
+
|
14 |
+
class DatabaseSelectRequest(BaseModel):
|
15 |
+
database_name: str
|
16 |
+
password: str
|
17 |
+
|
18 |
+
|
19 |
+
class DatabaseSelectResponse(BaseModel):
|
20 |
+
success: bool
|
21 |
+
message: str
|
app/models/dish.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, validator, field_serializer
|
2 |
+
from typing import Optional, List, Union
|
3 |
+
from datetime import datetime
|
4 |
+
import json
|
5 |
+
|
6 |
+
class DishBase(BaseModel):
|
7 |
+
name: str
|
8 |
+
description: Optional[str] = None
|
9 |
+
category: str # Keep as string - will store JSON array format
|
10 |
+
price: float
|
11 |
+
quantity: Optional[int] = 0 # Made optional for forms
|
12 |
+
discount: Optional[float] = 0
|
13 |
+
is_offer: Optional[int] = 0
|
14 |
+
is_special: Optional[int] = 0
|
15 |
+
is_vegetarian: Optional[int] = 1 # 1 = vegetarian, 0 = non-vegetarian
|
16 |
+
visibility: Optional[int] = 1
|
17 |
+
|
18 |
+
class DishCreate(DishBase):
|
19 |
+
pass
|
20 |
+
|
21 |
+
class DishUpdate(DishBase):
|
22 |
+
name: Optional[str] = None
|
23 |
+
description: Optional[str] = None
|
24 |
+
category: Optional[str] = None
|
25 |
+
price: Optional[float] = None
|
26 |
+
quantity: Optional[int] = None
|
27 |
+
image_path: Optional[str] = None
|
28 |
+
discount: Optional[float] = None
|
29 |
+
is_offer: Optional[int] = None
|
30 |
+
is_special: Optional[int] = None
|
31 |
+
is_vegetarian: Optional[int] = None
|
32 |
+
visibility: Optional[int] = None
|
33 |
+
|
34 |
+
class Dish(DishBase):
|
35 |
+
id: int
|
36 |
+
image_path: Optional[str] = None
|
37 |
+
visibility: int = 1
|
38 |
+
created_at: datetime
|
39 |
+
updated_at: datetime
|
40 |
+
|
41 |
+
class Config:
|
42 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/models/feedback.py
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
class FeedbackBase(BaseModel):
|
7 |
+
order_id: int
|
8 |
+
rating: int
|
9 |
+
comment: Optional[str] = None
|
10 |
+
person_id: Optional[int] = None
|
11 |
+
|
12 |
+
|
13 |
+
class FeedbackCreate(FeedbackBase):
|
14 |
+
pass
|
15 |
+
|
16 |
+
|
17 |
+
class Feedback(FeedbackBase):
|
18 |
+
id: int
|
19 |
+
created_at: datetime
|
20 |
+
|
21 |
+
class Config:
|
22 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/models/loyalty.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
class LoyaltyProgramBase(BaseModel):
|
7 |
+
visit_count: int
|
8 |
+
discount_percentage: float
|
9 |
+
is_active: bool = True
|
10 |
+
|
11 |
+
|
12 |
+
class LoyaltyProgramCreate(LoyaltyProgramBase):
|
13 |
+
pass
|
14 |
+
|
15 |
+
|
16 |
+
class LoyaltyProgramUpdate(BaseModel):
|
17 |
+
visit_count: Optional[int] = None
|
18 |
+
discount_percentage: Optional[float] = None
|
19 |
+
is_active: Optional[bool] = None
|
20 |
+
|
21 |
+
|
22 |
+
class LoyaltyProgram(LoyaltyProgramBase):
|
23 |
+
id: int
|
24 |
+
created_at: datetime
|
25 |
+
updated_at: datetime
|
26 |
+
|
27 |
+
class Config:
|
28 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/models/order.py
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import List, Optional
|
3 |
+
from datetime import datetime
|
4 |
+
from .dish import Dish
|
5 |
+
|
6 |
+
|
7 |
+
class OrderItemBase(BaseModel):
|
8 |
+
dish_id: int
|
9 |
+
quantity: int = 1
|
10 |
+
remarks: Optional[str] = None
|
11 |
+
|
12 |
+
|
13 |
+
class OrderItemCreate(OrderItemBase):
|
14 |
+
pass
|
15 |
+
|
16 |
+
|
17 |
+
class OrderItem(OrderItemBase):
|
18 |
+
id: int
|
19 |
+
order_id: int
|
20 |
+
created_at: datetime
|
21 |
+
dish: Optional[Dish] = None
|
22 |
+
|
23 |
+
# Add dish_name property to ensure it's always available
|
24 |
+
@property
|
25 |
+
def dish_name(self) -> str:
|
26 |
+
if self.dish:
|
27 |
+
return self.dish.name
|
28 |
+
return "Unknown Dish"
|
29 |
+
|
30 |
+
class Config:
|
31 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
32 |
+
|
33 |
+
|
34 |
+
class OrderBase(BaseModel):
|
35 |
+
table_number: int
|
36 |
+
unique_id: str
|
37 |
+
|
38 |
+
|
39 |
+
class OrderCreate(OrderBase):
|
40 |
+
items: List[OrderItemCreate]
|
41 |
+
username: Optional[str] = None
|
42 |
+
password: Optional[str] = None
|
43 |
+
|
44 |
+
|
45 |
+
class OrderUpdate(BaseModel):
|
46 |
+
status: str
|
47 |
+
|
48 |
+
|
49 |
+
class Order(OrderBase):
|
50 |
+
id: int
|
51 |
+
status: str
|
52 |
+
created_at: datetime
|
53 |
+
updated_at: datetime
|
54 |
+
items: List[OrderItem] = []
|
55 |
+
person_id: Optional[int] = None
|
56 |
+
person_name: Optional[str] = None
|
57 |
+
visit_count: Optional[int] = None
|
58 |
+
total_amount: Optional[float] = None
|
59 |
+
subtotal_amount: Optional[float] = None
|
60 |
+
loyalty_discount_amount: Optional[float] = 0
|
61 |
+
selection_offer_discount_amount: Optional[float] = 0
|
62 |
+
loyalty_discount_percentage: Optional[float] = 0
|
63 |
+
|
64 |
+
class Config:
|
65 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/models/selection_offer.py
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
class SelectionOfferBase(BaseModel):
|
7 |
+
min_amount: float
|
8 |
+
discount_amount: float
|
9 |
+
is_active: bool = True
|
10 |
+
description: Optional[str] = None
|
11 |
+
|
12 |
+
|
13 |
+
class SelectionOfferCreate(SelectionOfferBase):
|
14 |
+
pass
|
15 |
+
|
16 |
+
|
17 |
+
class SelectionOfferUpdate(BaseModel):
|
18 |
+
min_amount: Optional[float] = None
|
19 |
+
discount_amount: Optional[float] = None
|
20 |
+
is_active: Optional[bool] = None
|
21 |
+
description: Optional[str] = None
|
22 |
+
|
23 |
+
|
24 |
+
class SelectionOffer(SelectionOfferBase):
|
25 |
+
id: int
|
26 |
+
created_at: datetime
|
27 |
+
updated_at: datetime
|
28 |
+
|
29 |
+
class Config:
|
30 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/models/settings.py
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
class SettingsBase(BaseModel):
|
7 |
+
hotel_name: str
|
8 |
+
address: Optional[str] = None
|
9 |
+
contact_number: Optional[str] = None
|
10 |
+
email: Optional[str] = None
|
11 |
+
tax_id: Optional[str] = None
|
12 |
+
logo_path: Optional[str] = None
|
13 |
+
|
14 |
+
|
15 |
+
class SettingsCreate(SettingsBase):
|
16 |
+
pass
|
17 |
+
|
18 |
+
|
19 |
+
class SettingsUpdate(BaseModel):
|
20 |
+
hotel_name: Optional[str] = None
|
21 |
+
address: Optional[str] = None
|
22 |
+
contact_number: Optional[str] = None
|
23 |
+
email: Optional[str] = None
|
24 |
+
tax_id: Optional[str] = None
|
25 |
+
logo_path: Optional[str] = None
|
26 |
+
|
27 |
+
|
28 |
+
class Settings(SettingsBase):
|
29 |
+
id: int
|
30 |
+
created_at: datetime
|
31 |
+
updated_at: datetime
|
32 |
+
|
33 |
+
class Config:
|
34 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/models/table.py
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
class TableBase(BaseModel):
|
7 |
+
table_number: int
|
8 |
+
is_occupied: bool = False
|
9 |
+
current_order_id: Optional[int] = None
|
10 |
+
|
11 |
+
|
12 |
+
class TableCreate(TableBase):
|
13 |
+
pass
|
14 |
+
|
15 |
+
|
16 |
+
class TableUpdate(BaseModel):
|
17 |
+
is_occupied: Optional[bool] = None
|
18 |
+
current_order_id: Optional[int] = None
|
19 |
+
|
20 |
+
|
21 |
+
class Table(TableBase):
|
22 |
+
id: int
|
23 |
+
created_at: datetime
|
24 |
+
updated_at: datetime
|
25 |
+
|
26 |
+
class Config:
|
27 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
28 |
+
|
29 |
+
|
30 |
+
class TableStatus(BaseModel):
|
31 |
+
total_tables: int
|
32 |
+
occupied_tables: int
|
33 |
+
free_tables: int
|
app/models/user.py
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from typing import Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
class PersonBase(BaseModel):
|
6 |
+
username: str
|
7 |
+
|
8 |
+
class PersonCreate(PersonBase):
|
9 |
+
password: str
|
10 |
+
table_number: int
|
11 |
+
phone_number: Optional[str] = None
|
12 |
+
|
13 |
+
class PersonLogin(PersonBase):
|
14 |
+
password: str
|
15 |
+
table_number: int
|
16 |
+
|
17 |
+
class PhoneAuthRequest(BaseModel):
|
18 |
+
phone_number: str
|
19 |
+
table_number: int
|
20 |
+
|
21 |
+
class PhoneVerifyRequest(BaseModel):
|
22 |
+
phone_number: str
|
23 |
+
verification_code: str
|
24 |
+
table_number: int
|
25 |
+
|
26 |
+
class UsernameRequest(BaseModel):
|
27 |
+
phone_number: str
|
28 |
+
username: str
|
29 |
+
table_number: int
|
30 |
+
|
31 |
+
class Person(PersonBase):
|
32 |
+
id: int
|
33 |
+
visit_count: int
|
34 |
+
last_visit: datetime
|
35 |
+
created_at: datetime
|
36 |
+
phone_number: Optional[str] = None
|
37 |
+
|
38 |
+
class Config:
|
39 |
+
from_attributes = True # Updated from orm_mode for Pydantic V2
|
app/routers/admin.py
ADDED
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from sqlalchemy.orm import Session
|
4 |
+
from typing import List, Optional
|
5 |
+
import os
|
6 |
+
import shutil
|
7 |
+
from datetime import datetime, timezone
|
8 |
+
from ..utils.pdf_generator import generate_bill_pdf, generate_multi_order_bill_pdf
|
9 |
+
|
10 |
+
from ..database import get_db, Order, Dish, OrderItem, Person, Settings, get_session_db, get_session_current_database, get_hotel_id_from_request
|
11 |
+
from ..models.order import Order as OrderModel
|
12 |
+
from ..models.dish import Dish as DishModel, DishCreate, DishUpdate
|
13 |
+
from ..middleware import get_session_id
|
14 |
+
|
15 |
+
router = APIRouter(
|
16 |
+
prefix="/admin",
|
17 |
+
tags=["admin"],
|
18 |
+
responses={404: {"description": "Not found"}},
|
19 |
+
)
|
20 |
+
|
21 |
+
|
22 |
+
# Dependency to get session-aware database
|
23 |
+
def get_session_database(request: Request):
|
24 |
+
session_id = get_session_id(request)
|
25 |
+
return next(get_session_db(session_id))
|
26 |
+
|
27 |
+
|
28 |
+
# Get all orders with customer information
|
29 |
+
@router.get("/orders", response_model=List[OrderModel])
|
30 |
+
def get_all_orders(request: Request, status: str = None, db: Session = Depends(get_session_database)):
|
31 |
+
hotel_id = get_hotel_id_from_request(request)
|
32 |
+
|
33 |
+
query = db.query(Order).filter(Order.hotel_id == hotel_id)
|
34 |
+
|
35 |
+
if status:
|
36 |
+
query = query.filter(Order.status == status)
|
37 |
+
|
38 |
+
# Order by most recent first
|
39 |
+
orders = query.order_by(Order.created_at.desc()).all()
|
40 |
+
|
41 |
+
# Load person information for each order
|
42 |
+
for order in orders:
|
43 |
+
if order.person_id:
|
44 |
+
person = db.query(Person).filter(
|
45 |
+
Person.hotel_id == hotel_id,
|
46 |
+
Person.id == order.person_id
|
47 |
+
).first()
|
48 |
+
if person:
|
49 |
+
# Add person information to the order
|
50 |
+
order.person_name = person.username
|
51 |
+
order.visit_count = person.visit_count
|
52 |
+
|
53 |
+
# Load dish information for each order item
|
54 |
+
for item in order.items:
|
55 |
+
if not hasattr(item, "dish") or item.dish is None:
|
56 |
+
dish = db.query(Dish).filter(
|
57 |
+
Dish.hotel_id == hotel_id,
|
58 |
+
Dish.id == item.dish_id
|
59 |
+
).first()
|
60 |
+
if dish:
|
61 |
+
item.dish = dish
|
62 |
+
|
63 |
+
return orders
|
64 |
+
|
65 |
+
|
66 |
+
# Get all dishes (only visible ones)
|
67 |
+
@router.get("/api/dishes", response_model=List[DishModel])
|
68 |
+
def get_all_dishes(
|
69 |
+
request: Request,
|
70 |
+
is_offer: Optional[int] = None,
|
71 |
+
is_special: Optional[int] = None,
|
72 |
+
db: Session = Depends(get_session_database),
|
73 |
+
):
|
74 |
+
hotel_id = get_hotel_id_from_request(request)
|
75 |
+
|
76 |
+
query = db.query(Dish).filter(
|
77 |
+
Dish.hotel_id == hotel_id,
|
78 |
+
Dish.visibility == 1
|
79 |
+
) # Only visible dishes for this hotel
|
80 |
+
|
81 |
+
if is_offer is not None:
|
82 |
+
query = query.filter(Dish.is_offer == is_offer)
|
83 |
+
|
84 |
+
if is_special is not None:
|
85 |
+
query = query.filter(Dish.is_special == is_special)
|
86 |
+
|
87 |
+
dishes = query.all()
|
88 |
+
return dishes
|
89 |
+
|
90 |
+
|
91 |
+
# Get offer dishes (only visible ones)
|
92 |
+
@router.get("/api/offers", response_model=List[DishModel])
|
93 |
+
def get_offer_dishes(request: Request, db: Session = Depends(get_session_database)):
|
94 |
+
hotel_id = get_hotel_id_from_request(request)
|
95 |
+
dishes = db.query(Dish).filter(
|
96 |
+
Dish.hotel_id == hotel_id,
|
97 |
+
Dish.is_offer == 1,
|
98 |
+
Dish.visibility == 1
|
99 |
+
).all()
|
100 |
+
return dishes
|
101 |
+
|
102 |
+
|
103 |
+
# Get special dishes (only visible ones)
|
104 |
+
@router.get("/api/specials", response_model=List[DishModel])
|
105 |
+
def get_special_dishes(request: Request, db: Session = Depends(get_session_database)):
|
106 |
+
hotel_id = get_hotel_id_from_request(request)
|
107 |
+
dishes = db.query(Dish).filter(
|
108 |
+
Dish.hotel_id == hotel_id,
|
109 |
+
Dish.is_special == 1,
|
110 |
+
Dish.visibility == 1
|
111 |
+
).all()
|
112 |
+
return dishes
|
113 |
+
|
114 |
+
|
115 |
+
# Get dish by ID (only if visible)
|
116 |
+
@router.get("/api/dishes/{dish_id}", response_model=DishModel)
|
117 |
+
def get_dish(dish_id: int, request: Request, db: Session = Depends(get_session_database)):
|
118 |
+
hotel_id = get_hotel_id_from_request(request)
|
119 |
+
dish = db.query(Dish).filter(
|
120 |
+
Dish.hotel_id == hotel_id,
|
121 |
+
Dish.id == dish_id,
|
122 |
+
Dish.visibility == 1
|
123 |
+
).first()
|
124 |
+
if dish is None:
|
125 |
+
raise HTTPException(status_code=404, detail="Dish not found")
|
126 |
+
return dish
|
127 |
+
|
128 |
+
|
129 |
+
# Get all categories
|
130 |
+
@router.get("/api/categories")
|
131 |
+
def get_all_categories(request: Request, db: Session = Depends(get_session_database)):
|
132 |
+
hotel_id = get_hotel_id_from_request(request)
|
133 |
+
categories = db.query(Dish.category).filter(
|
134 |
+
Dish.hotel_id == hotel_id
|
135 |
+
).distinct().all()
|
136 |
+
|
137 |
+
# Parse JSON categories and flatten them
|
138 |
+
import json
|
139 |
+
unique_categories = set()
|
140 |
+
|
141 |
+
for category_tuple in categories:
|
142 |
+
category_str = category_tuple[0]
|
143 |
+
if category_str:
|
144 |
+
try:
|
145 |
+
# Try to parse as JSON array
|
146 |
+
category_list = json.loads(category_str)
|
147 |
+
if isinstance(category_list, list):
|
148 |
+
unique_categories.update(category_list)
|
149 |
+
else:
|
150 |
+
unique_categories.add(category_str)
|
151 |
+
except (json.JSONDecodeError, TypeError):
|
152 |
+
# If not JSON, treat as single category
|
153 |
+
unique_categories.add(category_str)
|
154 |
+
|
155 |
+
return sorted(list(unique_categories))
|
156 |
+
|
157 |
+
|
158 |
+
# Create new category
|
159 |
+
@router.post("/api/categories")
|
160 |
+
def create_category(request: Request, category_name: str = Form(...), db: Session = Depends(get_session_database)):
|
161 |
+
hotel_id = get_hotel_id_from_request(request)
|
162 |
+
|
163 |
+
# Check if category already exists for this hotel
|
164 |
+
existing_category = (
|
165 |
+
db.query(Dish.category).filter(
|
166 |
+
Dish.hotel_id == hotel_id,
|
167 |
+
Dish.category == category_name
|
168 |
+
).first()
|
169 |
+
)
|
170 |
+
if existing_category:
|
171 |
+
raise HTTPException(status_code=400, detail="Category already exists")
|
172 |
+
|
173 |
+
return {"message": "Category created successfully", "category": category_name}
|
174 |
+
|
175 |
+
|
176 |
+
# Create new dish
|
177 |
+
@router.post("/api/dishes", response_model=DishModel)
|
178 |
+
async def create_dish(
|
179 |
+
request: Request,
|
180 |
+
name: str = Form(...),
|
181 |
+
description: Optional[str] = Form(None),
|
182 |
+
category: Optional[str] = Form(None), # Made optional
|
183 |
+
new_category: Optional[str] = Form(None), # New field for custom category
|
184 |
+
categories: Optional[str] = Form(None), # JSON array of multiple categories
|
185 |
+
price: float = Form(...),
|
186 |
+
quantity: Optional[int] = Form(0), # Made optional with default
|
187 |
+
discount: Optional[float] = Form(0), # Discount amount (percentage)
|
188 |
+
is_offer: Optional[int] = Form(0), # Whether this dish is part of offers
|
189 |
+
is_special: Optional[int] = Form(0), # Whether this dish is today's special
|
190 |
+
is_vegetarian: int = Form(...), # Required: 1 = vegetarian, 0 = non-vegetarian
|
191 |
+
image: Optional[UploadFile] = File(None),
|
192 |
+
db: Session = Depends(get_session_database),
|
193 |
+
):
|
194 |
+
hotel_id = get_hotel_id_from_request(request)
|
195 |
+
|
196 |
+
# Handle categories - support both single and multiple categories
|
197 |
+
import json
|
198 |
+
final_category = None
|
199 |
+
|
200 |
+
if categories:
|
201 |
+
# Multiple categories provided as JSON array
|
202 |
+
try:
|
203 |
+
category_list = json.loads(categories)
|
204 |
+
final_category = json.dumps(category_list) # Store as JSON string
|
205 |
+
except json.JSONDecodeError:
|
206 |
+
final_category = json.dumps([categories]) # Fallback to single category
|
207 |
+
elif new_category:
|
208 |
+
# Single new category
|
209 |
+
final_category = json.dumps([new_category])
|
210 |
+
elif category:
|
211 |
+
# Single existing category
|
212 |
+
final_category = json.dumps([category])
|
213 |
+
else:
|
214 |
+
# Default category if none provided
|
215 |
+
final_category = json.dumps(["Uncategorized"])
|
216 |
+
|
217 |
+
# Create dish object
|
218 |
+
db_dish = Dish(
|
219 |
+
hotel_id=hotel_id,
|
220 |
+
name=name,
|
221 |
+
description=description,
|
222 |
+
category=final_category,
|
223 |
+
price=price,
|
224 |
+
quantity=quantity,
|
225 |
+
discount=discount,
|
226 |
+
is_offer=is_offer,
|
227 |
+
is_special=is_special,
|
228 |
+
is_vegetarian=is_vegetarian,
|
229 |
+
)
|
230 |
+
|
231 |
+
# Save dish to database
|
232 |
+
db.add(db_dish)
|
233 |
+
db.commit()
|
234 |
+
db.refresh(db_dish)
|
235 |
+
|
236 |
+
# Handle image upload if provided
|
237 |
+
if image:
|
238 |
+
# Get hotel info for organizing images
|
239 |
+
from ..database import Hotel
|
240 |
+
hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first()
|
241 |
+
hotel_name_for_path = hotel.hotel_name if hotel else f"hotel_{hotel_id}"
|
242 |
+
|
243 |
+
# Create directory structure: app/static/images/dishes/{hotel_name}
|
244 |
+
hotel_images_dir = f"app/static/images/dishes/{hotel_name_for_path}"
|
245 |
+
os.makedirs(hotel_images_dir, exist_ok=True)
|
246 |
+
|
247 |
+
# Save image with hotel-specific path
|
248 |
+
image_path = f"{hotel_images_dir}/{db_dish.id}_{image.filename}"
|
249 |
+
with open(image_path, "wb") as buffer:
|
250 |
+
shutil.copyfileobj(image.file, buffer)
|
251 |
+
|
252 |
+
# Update dish with image path (URL path for serving)
|
253 |
+
db_dish.image_path = f"/static/images/dishes/{hotel_name_for_path}/{db_dish.id}_{image.filename}"
|
254 |
+
db.commit()
|
255 |
+
db.refresh(db_dish)
|
256 |
+
|
257 |
+
return db_dish
|
258 |
+
|
259 |
+
|
260 |
+
# Update dish
|
261 |
+
@router.put("/api/dishes/{dish_id}", response_model=DishModel)
|
262 |
+
async def update_dish(
|
263 |
+
dish_id: int,
|
264 |
+
request: Request,
|
265 |
+
name: Optional[str] = Form(None),
|
266 |
+
description: Optional[str] = Form(None),
|
267 |
+
category: Optional[str] = Form(None),
|
268 |
+
new_category: Optional[str] = Form(None), # New field for custom category
|
269 |
+
categories: Optional[str] = Form(None), # JSON array of multiple categories
|
270 |
+
price: Optional[float] = Form(None),
|
271 |
+
quantity: Optional[int] = Form(None),
|
272 |
+
discount: Optional[float] = Form(None), # Discount amount (percentage)
|
273 |
+
is_offer: Optional[int] = Form(None), # Whether this dish is part of offers
|
274 |
+
is_special: Optional[int] = Form(None), # Whether this dish is today's special
|
275 |
+
is_vegetarian: Optional[int] = Form(None), # 1 = vegetarian, 0 = non-vegetarian
|
276 |
+
image: Optional[UploadFile] = File(None),
|
277 |
+
db: Session = Depends(get_session_database),
|
278 |
+
):
|
279 |
+
hotel_id = get_hotel_id_from_request(request)
|
280 |
+
|
281 |
+
# Get existing dish for this hotel
|
282 |
+
db_dish = db.query(Dish).filter(
|
283 |
+
Dish.hotel_id == hotel_id,
|
284 |
+
Dish.id == dish_id
|
285 |
+
).first()
|
286 |
+
if db_dish is None:
|
287 |
+
raise HTTPException(status_code=404, detail="Dish not found")
|
288 |
+
|
289 |
+
# Update fields if provided
|
290 |
+
if name:
|
291 |
+
db_dish.name = name
|
292 |
+
if description:
|
293 |
+
db_dish.description = description
|
294 |
+
|
295 |
+
# Handle categories - support both single and multiple categories
|
296 |
+
import json
|
297 |
+
if categories:
|
298 |
+
# Multiple categories provided as JSON array
|
299 |
+
try:
|
300 |
+
category_list = json.loads(categories)
|
301 |
+
db_dish.category = json.dumps(category_list)
|
302 |
+
except json.JSONDecodeError:
|
303 |
+
db_dish.category = json.dumps([categories])
|
304 |
+
elif new_category: # Use new category if provided
|
305 |
+
db_dish.category = json.dumps([new_category])
|
306 |
+
elif category:
|
307 |
+
db_dish.category = json.dumps([category])
|
308 |
+
|
309 |
+
if price:
|
310 |
+
db_dish.price = price
|
311 |
+
if quantity is not None: # Allow 0 quantity
|
312 |
+
db_dish.quantity = quantity
|
313 |
+
if discount is not None:
|
314 |
+
db_dish.discount = discount
|
315 |
+
if is_offer is not None:
|
316 |
+
db_dish.is_offer = is_offer
|
317 |
+
if is_special is not None:
|
318 |
+
db_dish.is_special = is_special
|
319 |
+
if is_vegetarian is not None:
|
320 |
+
db_dish.is_vegetarian = is_vegetarian
|
321 |
+
|
322 |
+
# Handle image upload if provided
|
323 |
+
if image:
|
324 |
+
# Get current database name for organizing images
|
325 |
+
session_id = get_session_id(request)
|
326 |
+
current_db = get_session_current_database(session_id)
|
327 |
+
|
328 |
+
# Create directory structure: app/static/images/dishes/{db_name}
|
329 |
+
db_images_dir = f"app/static/images/dishes/{current_db}"
|
330 |
+
os.makedirs(db_images_dir, exist_ok=True)
|
331 |
+
|
332 |
+
# Save image with database-specific path
|
333 |
+
image_path = f"{db_images_dir}/{db_dish.id}_{image.filename}"
|
334 |
+
with open(image_path, "wb") as buffer:
|
335 |
+
shutil.copyfileobj(image.file, buffer)
|
336 |
+
|
337 |
+
# Update dish with image path (URL path for serving)
|
338 |
+
db_dish.image_path = f"/static/images/dishes/{current_db}/{db_dish.id}_{image.filename}"
|
339 |
+
|
340 |
+
# Update timestamp
|
341 |
+
db_dish.updated_at = datetime.now(timezone.utc)
|
342 |
+
|
343 |
+
# Save changes
|
344 |
+
db.commit()
|
345 |
+
db.refresh(db_dish)
|
346 |
+
|
347 |
+
return db_dish
|
348 |
+
|
349 |
+
|
350 |
+
# Soft delete dish (set visibility to 0)
|
351 |
+
@router.delete("/api/dishes/{dish_id}")
|
352 |
+
def delete_dish(dish_id: int, request: Request, db: Session = Depends(get_session_database)):
|
353 |
+
hotel_id = get_hotel_id_from_request(request)
|
354 |
+
|
355 |
+
db_dish = db.query(Dish).filter(
|
356 |
+
Dish.hotel_id == hotel_id,
|
357 |
+
Dish.id == dish_id,
|
358 |
+
Dish.visibility == 1
|
359 |
+
).first()
|
360 |
+
if db_dish is None:
|
361 |
+
raise HTTPException(status_code=404, detail="Dish not found")
|
362 |
+
|
363 |
+
# Soft delete: set visibility to 0 instead of actually deleting
|
364 |
+
db_dish.visibility = 0
|
365 |
+
db_dish.updated_at = datetime.now(timezone.utc)
|
366 |
+
db.commit()
|
367 |
+
|
368 |
+
return {"message": "Dish deleted successfully"}
|
369 |
+
|
370 |
+
|
371 |
+
# Get order statistics
|
372 |
+
@router.get("/stats/orders")
|
373 |
+
def get_order_stats(request: Request, db: Session = Depends(get_session_database)):
|
374 |
+
from sqlalchemy import func, and_
|
375 |
+
|
376 |
+
# Get today's date range (start and end of today in UTC)
|
377 |
+
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
378 |
+
today_end = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=999999)
|
379 |
+
|
380 |
+
# Overall statistics
|
381 |
+
total_orders = db.query(Order).count()
|
382 |
+
pending_orders = db.query(Order).filter(Order.status == "pending").count()
|
383 |
+
completed_orders = db.query(Order).filter(Order.status == "completed").count()
|
384 |
+
payment_requested = (
|
385 |
+
db.query(Order).filter(Order.status == "payment_requested").count()
|
386 |
+
)
|
387 |
+
paid_orders = db.query(Order).filter(Order.status == "paid").count()
|
388 |
+
|
389 |
+
# Today's statistics
|
390 |
+
total_orders_today = db.query(Order).filter(
|
391 |
+
and_(Order.created_at >= today_start, Order.created_at <= today_end)
|
392 |
+
).count()
|
393 |
+
|
394 |
+
pending_orders_today = db.query(Order).filter(
|
395 |
+
and_(
|
396 |
+
Order.status == "pending",
|
397 |
+
Order.created_at >= today_start,
|
398 |
+
Order.created_at <= today_end
|
399 |
+
)
|
400 |
+
).count()
|
401 |
+
|
402 |
+
completed_orders_today = db.query(Order).filter(
|
403 |
+
and_(
|
404 |
+
Order.status == "completed",
|
405 |
+
Order.created_at >= today_start,
|
406 |
+
Order.created_at <= today_end
|
407 |
+
)
|
408 |
+
).count()
|
409 |
+
|
410 |
+
paid_orders_today = db.query(Order).filter(
|
411 |
+
and_(
|
412 |
+
Order.status == "paid",
|
413 |
+
Order.created_at >= today_start,
|
414 |
+
Order.created_at <= today_end
|
415 |
+
)
|
416 |
+
).count()
|
417 |
+
|
418 |
+
# Calculate today's revenue from paid orders
|
419 |
+
revenue_today_query = (
|
420 |
+
db.query(
|
421 |
+
func.sum(Dish.price * OrderItem.quantity).label("revenue_today")
|
422 |
+
)
|
423 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
424 |
+
.join(Order, OrderItem.order_id == Order.id)
|
425 |
+
.filter(Order.status == "paid")
|
426 |
+
.filter(
|
427 |
+
and_(
|
428 |
+
Order.created_at >= today_start,
|
429 |
+
Order.created_at <= today_end
|
430 |
+
)
|
431 |
+
)
|
432 |
+
)
|
433 |
+
|
434 |
+
revenue_today_result = revenue_today_query.first()
|
435 |
+
revenue_today = revenue_today_result.revenue_today if revenue_today_result.revenue_today else 0
|
436 |
+
|
437 |
+
return {
|
438 |
+
"total_orders": total_orders,
|
439 |
+
"pending_orders": pending_orders,
|
440 |
+
"completed_orders": completed_orders,
|
441 |
+
"payment_requested": payment_requested,
|
442 |
+
"paid_orders": paid_orders,
|
443 |
+
"total_orders_today": total_orders_today,
|
444 |
+
"pending_orders_today": pending_orders_today,
|
445 |
+
"completed_orders_today": completed_orders_today,
|
446 |
+
"paid_orders_today": paid_orders_today,
|
447 |
+
"revenue_today": round(revenue_today, 2),
|
448 |
+
}
|
449 |
+
|
450 |
+
|
451 |
+
# Mark order as paid
|
452 |
+
@router.put("/orders/{order_id}/paid")
|
453 |
+
def mark_order_paid(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
454 |
+
db_order = db.query(Order).filter(Order.id == order_id).first()
|
455 |
+
if db_order is None:
|
456 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
457 |
+
|
458 |
+
# Allow marking as paid from any status
|
459 |
+
db_order.status = "paid"
|
460 |
+
db_order.updated_at = datetime.now(timezone.utc)
|
461 |
+
|
462 |
+
db.commit()
|
463 |
+
|
464 |
+
return {"message": "Order marked as paid"}
|
465 |
+
|
466 |
+
|
467 |
+
# Generate bill PDF for a single order
|
468 |
+
@router.get("/orders/{order_id}/bill")
|
469 |
+
def generate_bill(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
470 |
+
# Get order with all details
|
471 |
+
db_order = db.query(Order).filter(Order.id == order_id).first()
|
472 |
+
if db_order is None:
|
473 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
474 |
+
|
475 |
+
# Load person information if available
|
476 |
+
if db_order.person_id:
|
477 |
+
person = db.query(Person).filter(Person.id == db_order.person_id).first()
|
478 |
+
if person:
|
479 |
+
db_order.person_name = person.username
|
480 |
+
|
481 |
+
# Load dish information for each order item
|
482 |
+
for item in db_order.items:
|
483 |
+
if not hasattr(item, "dish") or item.dish is None:
|
484 |
+
dish = db.query(Dish).filter(Dish.id == item.dish_id).first()
|
485 |
+
if dish:
|
486 |
+
item.dish = dish
|
487 |
+
|
488 |
+
# Get hotel ID from request
|
489 |
+
hotel_id = get_hotel_id_from_request(request)
|
490 |
+
|
491 |
+
# Get hotel settings for this specific hotel
|
492 |
+
settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first()
|
493 |
+
if not settings:
|
494 |
+
# Create default settings if none exist for this hotel
|
495 |
+
settings = Settings(
|
496 |
+
hotel_id=hotel_id,
|
497 |
+
hotel_name="Tabble Hotel",
|
498 |
+
address="123 Main Street, City",
|
499 |
+
contact_number="+1 123-456-7890",
|
500 |
+
email="[email protected]",
|
501 |
+
)
|
502 |
+
db.add(settings)
|
503 |
+
db.commit()
|
504 |
+
db.refresh(settings)
|
505 |
+
|
506 |
+
# Generate PDF
|
507 |
+
pdf_buffer = generate_bill_pdf(db_order, settings)
|
508 |
+
|
509 |
+
# Return PDF as a downloadable file
|
510 |
+
filename = f"bill_order_{order_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf"
|
511 |
+
|
512 |
+
return StreamingResponse(
|
513 |
+
pdf_buffer,
|
514 |
+
media_type="application/pdf",
|
515 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
516 |
+
)
|
517 |
+
|
518 |
+
|
519 |
+
# Generate bill PDF for multiple orders
|
520 |
+
@router.post("/orders/multi-bill")
|
521 |
+
def generate_multi_bill(order_ids: List[int], request: Request, db: Session = Depends(get_session_database)):
|
522 |
+
if not order_ids:
|
523 |
+
raise HTTPException(status_code=400, detail="No order IDs provided")
|
524 |
+
|
525 |
+
orders = []
|
526 |
+
|
527 |
+
# Get all orders with details
|
528 |
+
for order_id in order_ids:
|
529 |
+
db_order = db.query(Order).filter(Order.id == order_id).first()
|
530 |
+
if db_order is None:
|
531 |
+
raise HTTPException(status_code=404, detail=f"Order {order_id} not found")
|
532 |
+
|
533 |
+
# Load person information if available
|
534 |
+
if db_order.person_id:
|
535 |
+
person = db.query(Person).filter(Person.id == db_order.person_id).first()
|
536 |
+
if person:
|
537 |
+
db_order.person_name = person.username
|
538 |
+
|
539 |
+
# Load dish information for each order item
|
540 |
+
for item in db_order.items:
|
541 |
+
if not hasattr(item, "dish") or item.dish is None:
|
542 |
+
dish = db.query(Dish).filter(Dish.id == item.dish_id).first()
|
543 |
+
if dish:
|
544 |
+
item.dish = dish
|
545 |
+
|
546 |
+
orders.append(db_order)
|
547 |
+
|
548 |
+
# Get hotel ID from request
|
549 |
+
hotel_id = get_hotel_id_from_request(request)
|
550 |
+
|
551 |
+
# Get hotel settings for this specific hotel
|
552 |
+
settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first()
|
553 |
+
if not settings:
|
554 |
+
# Create default settings if none exist for this hotel
|
555 |
+
settings = Settings(
|
556 |
+
hotel_id=hotel_id,
|
557 |
+
hotel_name="Tabble Hotel",
|
558 |
+
address="123 Main Street, City",
|
559 |
+
contact_number="+1 123-456-7890",
|
560 |
+
email="[email protected]",
|
561 |
+
)
|
562 |
+
db.add(settings)
|
563 |
+
db.commit()
|
564 |
+
db.refresh(settings)
|
565 |
+
|
566 |
+
# Generate PDF for multiple orders
|
567 |
+
pdf_buffer = generate_multi_order_bill_pdf(orders, settings)
|
568 |
+
|
569 |
+
# Create a filename with all order IDs
|
570 |
+
order_ids_str = "-".join([str(order_id) for order_id in order_ids])
|
571 |
+
filename = f"bill_orders_{order_ids_str}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf"
|
572 |
+
|
573 |
+
return StreamingResponse(
|
574 |
+
pdf_buffer,
|
575 |
+
media_type="application/pdf",
|
576 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
577 |
+
)
|
578 |
+
|
579 |
+
|
580 |
+
# Merge two orders
|
581 |
+
@router.post("/orders/merge")
|
582 |
+
def merge_orders(source_order_id: int, target_order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
583 |
+
# Get both orders
|
584 |
+
source_order = db.query(Order).filter(Order.id == source_order_id).first()
|
585 |
+
target_order = db.query(Order).filter(Order.id == target_order_id).first()
|
586 |
+
|
587 |
+
if not source_order:
|
588 |
+
raise HTTPException(status_code=404, detail=f"Source order {source_order_id} not found")
|
589 |
+
|
590 |
+
if not target_order:
|
591 |
+
raise HTTPException(status_code=404, detail=f"Target order {target_order_id} not found")
|
592 |
+
|
593 |
+
# Check if both orders are completed or paid
|
594 |
+
valid_statuses = ["completed", "paid"]
|
595 |
+
if source_order.status not in valid_statuses:
|
596 |
+
raise HTTPException(status_code=400, detail=f"Source order must be completed or paid, current status: {source_order.status}")
|
597 |
+
|
598 |
+
if target_order.status not in valid_statuses:
|
599 |
+
raise HTTPException(status_code=400, detail=f"Target order must be completed or paid, current status: {target_order.status}")
|
600 |
+
|
601 |
+
# Move all items from source order to target order
|
602 |
+
for item in source_order.items:
|
603 |
+
# Update the order_id to point to the target order
|
604 |
+
item.order_id = target_order.id
|
605 |
+
|
606 |
+
# Update the target order's updated_at timestamp
|
607 |
+
target_order.updated_at = datetime.now(timezone.utc)
|
608 |
+
|
609 |
+
# Delete the source order (but keep its items which now belong to the target order)
|
610 |
+
db.delete(source_order)
|
611 |
+
|
612 |
+
# Commit changes
|
613 |
+
db.commit()
|
614 |
+
|
615 |
+
# Refresh the target order to include the new items
|
616 |
+
db.refresh(target_order)
|
617 |
+
|
618 |
+
return {"message": f"Orders merged successfully. Items from order #{source_order_id} have been moved to order #{target_order_id}"}
|
619 |
+
|
620 |
+
|
621 |
+
# Get completed orders for billing (paid orders)
|
622 |
+
@router.get("/orders/completed-for-billing", response_model=List[OrderModel])
|
623 |
+
def get_completed_orders_for_billing(request: Request, db: Session = Depends(get_session_database)):
|
624 |
+
# Get paid orders ordered by most recent first
|
625 |
+
orders = db.query(Order).filter(Order.status == "paid").order_by(Order.created_at.desc()).all()
|
626 |
+
|
627 |
+
# Load person information for each order
|
628 |
+
for order in orders:
|
629 |
+
if order.person_id:
|
630 |
+
person = db.query(Person).filter(Person.id == order.person_id).first()
|
631 |
+
if person:
|
632 |
+
# Add person information to the order
|
633 |
+
order.person_name = person.username
|
634 |
+
order.visit_count = person.visit_count
|
635 |
+
|
636 |
+
# Load dish information for each order item
|
637 |
+
for item in order.items:
|
638 |
+
if not hasattr(item, "dish") or item.dish is None:
|
639 |
+
dish = db.query(Dish).filter(Dish.id == item.dish_id).first()
|
640 |
+
if dish:
|
641 |
+
item.dish = dish
|
642 |
+
|
643 |
+
return orders
|
app/routers/analytics.py
ADDED
@@ -0,0 +1,573 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from sqlalchemy import func, desc, extract
|
4 |
+
from typing import List, Dict, Any
|
5 |
+
from datetime import datetime, timedelta, timezone
|
6 |
+
import calendar
|
7 |
+
|
8 |
+
from ..database import get_db, Dish, Order, OrderItem, Person, Table, Feedback, get_session_db
|
9 |
+
from ..models.dish import Dish as DishModel
|
10 |
+
from ..models.order import Order as OrderModel
|
11 |
+
from ..models.user import Person as PersonModel
|
12 |
+
from ..models.feedback import Feedback as FeedbackModel
|
13 |
+
from ..middleware import get_session_id
|
14 |
+
|
15 |
+
router = APIRouter(
|
16 |
+
prefix="/analytics",
|
17 |
+
tags=["analytics"],
|
18 |
+
responses={404: {"description": "Not found"}},
|
19 |
+
)
|
20 |
+
|
21 |
+
|
22 |
+
# Dependency to get session-aware database
|
23 |
+
def get_session_database(request: Request):
|
24 |
+
session_id = get_session_id(request)
|
25 |
+
return next(get_session_db(session_id))
|
26 |
+
|
27 |
+
|
28 |
+
# Get overall dashboard statistics
|
29 |
+
@router.get("/dashboard")
|
30 |
+
def get_dashboard_stats(
|
31 |
+
request: Request,
|
32 |
+
start_date: str = None,
|
33 |
+
end_date: str = None,
|
34 |
+
db: Session = Depends(get_session_database)
|
35 |
+
):
|
36 |
+
# Parse date strings to datetime objects if provided
|
37 |
+
start_datetime = None
|
38 |
+
end_datetime = None
|
39 |
+
|
40 |
+
if start_date:
|
41 |
+
try:
|
42 |
+
start_datetime = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
43 |
+
except ValueError:
|
44 |
+
raise HTTPException(status_code=400, detail="Invalid start_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
|
45 |
+
|
46 |
+
if end_date:
|
47 |
+
try:
|
48 |
+
end_datetime = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
49 |
+
except ValueError:
|
50 |
+
raise HTTPException(status_code=400, detail="Invalid end_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
|
51 |
+
|
52 |
+
# Base query for orders
|
53 |
+
orders_query = db.query(Order)
|
54 |
+
|
55 |
+
# Apply date filters if provided
|
56 |
+
if start_datetime:
|
57 |
+
orders_query = orders_query.filter(Order.created_at >= start_datetime)
|
58 |
+
|
59 |
+
if end_datetime:
|
60 |
+
orders_query = orders_query.filter(Order.created_at <= end_datetime)
|
61 |
+
|
62 |
+
# Total sales
|
63 |
+
total_sales_query = (
|
64 |
+
db.query(
|
65 |
+
func.sum(Dish.price * OrderItem.quantity).label("total_sales")
|
66 |
+
)
|
67 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
68 |
+
.join(Order, OrderItem.order_id == Order.id)
|
69 |
+
.filter(Order.status == "paid")
|
70 |
+
)
|
71 |
+
|
72 |
+
# Apply date filters to sales query
|
73 |
+
if start_datetime:
|
74 |
+
total_sales_query = total_sales_query.filter(Order.created_at >= start_datetime)
|
75 |
+
|
76 |
+
if end_datetime:
|
77 |
+
total_sales_query = total_sales_query.filter(Order.created_at <= end_datetime)
|
78 |
+
|
79 |
+
total_sales_result = total_sales_query.first()
|
80 |
+
total_sales = total_sales_result.total_sales if total_sales_result.total_sales else 0
|
81 |
+
|
82 |
+
# Total customers (only count those who placed orders in the date range)
|
83 |
+
if start_datetime or end_datetime:
|
84 |
+
# Get unique person_ids from filtered orders
|
85 |
+
person_subquery = orders_query.with_entities(Order.person_id).distinct().subquery()
|
86 |
+
total_customers = db.query(Person).filter(Person.id.in_(person_subquery)).count()
|
87 |
+
else:
|
88 |
+
total_customers = db.query(Person).count()
|
89 |
+
|
90 |
+
# Total orders
|
91 |
+
total_orders = orders_query.count()
|
92 |
+
|
93 |
+
# Total dishes
|
94 |
+
total_dishes = db.query(Dish).count()
|
95 |
+
|
96 |
+
# Average order value
|
97 |
+
avg_order_value_query = (
|
98 |
+
db.query(
|
99 |
+
func.avg(
|
100 |
+
db.query(func.sum(Dish.price * OrderItem.quantity))
|
101 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
102 |
+
.filter(OrderItem.order_id == Order.id)
|
103 |
+
.scalar_subquery()
|
104 |
+
).label("avg_order_value")
|
105 |
+
)
|
106 |
+
.filter(Order.status == "paid")
|
107 |
+
)
|
108 |
+
|
109 |
+
# Apply date filters to avg order value query
|
110 |
+
if start_datetime:
|
111 |
+
avg_order_value_query = avg_order_value_query.filter(Order.created_at >= start_datetime)
|
112 |
+
|
113 |
+
if end_datetime:
|
114 |
+
avg_order_value_query = avg_order_value_query.filter(Order.created_at <= end_datetime)
|
115 |
+
|
116 |
+
avg_order_value_result = avg_order_value_query.first()
|
117 |
+
avg_order_value = avg_order_value_result.avg_order_value if avg_order_value_result.avg_order_value else 0
|
118 |
+
|
119 |
+
# Return all stats
|
120 |
+
return {
|
121 |
+
"total_sales": round(total_sales, 2),
|
122 |
+
"total_customers": total_customers,
|
123 |
+
"total_orders": total_orders,
|
124 |
+
"total_dishes": total_dishes,
|
125 |
+
"avg_order_value": round(avg_order_value, 2),
|
126 |
+
"date_range": {
|
127 |
+
"start_date": start_date,
|
128 |
+
"end_date": end_date
|
129 |
+
}
|
130 |
+
}
|
131 |
+
|
132 |
+
|
133 |
+
# Get top customers by order count
|
134 |
+
@router.get("/top-customers")
|
135 |
+
def get_top_customers(request: Request, limit: int = 10, db: Session = Depends(get_session_database)):
|
136 |
+
# Get customers with most orders
|
137 |
+
top_customers_by_orders = (
|
138 |
+
db.query(
|
139 |
+
Person.id,
|
140 |
+
Person.username,
|
141 |
+
Person.visit_count,
|
142 |
+
Person.last_visit,
|
143 |
+
func.count(Order.id).label("order_count"),
|
144 |
+
func.sum(
|
145 |
+
db.query(func.sum(Dish.price * OrderItem.quantity))
|
146 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
147 |
+
.filter(OrderItem.order_id == Order.id)
|
148 |
+
.scalar_subquery()
|
149 |
+
).label("total_spent"),
|
150 |
+
)
|
151 |
+
.join(Order, Person.id == Order.person_id)
|
152 |
+
.group_by(Person.id)
|
153 |
+
.order_by(desc("order_count"))
|
154 |
+
.limit(limit)
|
155 |
+
.all()
|
156 |
+
)
|
157 |
+
|
158 |
+
# Format the results
|
159 |
+
result = []
|
160 |
+
for customer in top_customers_by_orders:
|
161 |
+
result.append({
|
162 |
+
"id": customer.id,
|
163 |
+
"username": customer.username,
|
164 |
+
"visit_count": customer.visit_count,
|
165 |
+
"last_visit": customer.last_visit,
|
166 |
+
"order_count": customer.order_count,
|
167 |
+
"total_spent": round(customer.total_spent, 2) if customer.total_spent else 0,
|
168 |
+
"avg_order_value": round(customer.total_spent / customer.order_count, 2) if customer.total_spent else 0,
|
169 |
+
})
|
170 |
+
|
171 |
+
return result
|
172 |
+
|
173 |
+
|
174 |
+
# Get top selling dishes
|
175 |
+
@router.get("/top-dishes")
|
176 |
+
def get_top_dishes(request: Request, limit: int = 10, db: Session = Depends(get_session_database)):
|
177 |
+
# Get dishes with most orders
|
178 |
+
top_dishes = (
|
179 |
+
db.query(
|
180 |
+
Dish.id,
|
181 |
+
Dish.name,
|
182 |
+
Dish.category,
|
183 |
+
Dish.price,
|
184 |
+
func.sum(OrderItem.quantity).label("total_ordered"),
|
185 |
+
func.sum(Dish.price * OrderItem.quantity).label("total_revenue"),
|
186 |
+
)
|
187 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
188 |
+
.join(Order, OrderItem.order_id == Order.id)
|
189 |
+
.filter(Order.status == "paid")
|
190 |
+
.group_by(Dish.id)
|
191 |
+
.order_by(desc("total_ordered"))
|
192 |
+
.limit(limit)
|
193 |
+
.all()
|
194 |
+
)
|
195 |
+
|
196 |
+
# Format the results
|
197 |
+
result = []
|
198 |
+
for dish in top_dishes:
|
199 |
+
result.append({
|
200 |
+
"id": dish.id,
|
201 |
+
"name": dish.name,
|
202 |
+
"category": dish.category,
|
203 |
+
"price": dish.price,
|
204 |
+
"total_ordered": dish.total_ordered,
|
205 |
+
"total_revenue": round(dish.total_revenue, 2),
|
206 |
+
})
|
207 |
+
|
208 |
+
return result
|
209 |
+
|
210 |
+
|
211 |
+
# Get sales by category
|
212 |
+
@router.get("/sales-by-category")
|
213 |
+
def get_sales_by_category(request: Request, db: Session = Depends(get_session_database)):
|
214 |
+
# Get sales by category
|
215 |
+
sales_by_category = (
|
216 |
+
db.query(
|
217 |
+
Dish.category,
|
218 |
+
func.sum(OrderItem.quantity).label("total_ordered"),
|
219 |
+
func.sum(Dish.price * OrderItem.quantity).label("total_revenue"),
|
220 |
+
)
|
221 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
222 |
+
.join(Order, OrderItem.order_id == Order.id)
|
223 |
+
.filter(Order.status == "paid")
|
224 |
+
.group_by(Dish.category)
|
225 |
+
.order_by(desc("total_revenue"))
|
226 |
+
.all()
|
227 |
+
)
|
228 |
+
|
229 |
+
# Format the results
|
230 |
+
result = []
|
231 |
+
for category in sales_by_category:
|
232 |
+
result.append({
|
233 |
+
"category": category.category,
|
234 |
+
"total_ordered": category.total_ordered,
|
235 |
+
"total_revenue": round(category.total_revenue, 2),
|
236 |
+
})
|
237 |
+
|
238 |
+
return result
|
239 |
+
|
240 |
+
|
241 |
+
# Get sales over time (daily for the last 30 days)
|
242 |
+
@router.get("/sales-over-time")
|
243 |
+
def get_sales_over_time(request: Request, days: int = 30, db: Session = Depends(get_session_database)):
|
244 |
+
# Calculate the date range
|
245 |
+
end_date = datetime.now(timezone.utc)
|
246 |
+
start_date = end_date - timedelta(days=days)
|
247 |
+
|
248 |
+
# Get sales by day
|
249 |
+
sales_by_day = (
|
250 |
+
db.query(
|
251 |
+
func.date(Order.created_at).label("date"),
|
252 |
+
func.count(Order.id).label("order_count"),
|
253 |
+
func.sum(
|
254 |
+
db.query(func.sum(Dish.price * OrderItem.quantity))
|
255 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
256 |
+
.filter(OrderItem.order_id == Order.id)
|
257 |
+
.scalar_subquery()
|
258 |
+
).label("total_sales"),
|
259 |
+
)
|
260 |
+
.filter(Order.status == "paid")
|
261 |
+
.filter(Order.created_at >= start_date)
|
262 |
+
.filter(Order.created_at <= end_date)
|
263 |
+
.group_by(func.date(Order.created_at))
|
264 |
+
.order_by(func.date(Order.created_at))
|
265 |
+
.all()
|
266 |
+
)
|
267 |
+
|
268 |
+
# Create a dictionary with all dates in the range
|
269 |
+
date_range = {}
|
270 |
+
current_date = start_date
|
271 |
+
while current_date <= end_date:
|
272 |
+
date_str = current_date.strftime("%Y-%m-%d")
|
273 |
+
date_range[date_str] = {"order_count": 0, "total_sales": 0}
|
274 |
+
current_date += timedelta(days=1)
|
275 |
+
|
276 |
+
# Fill in the actual data
|
277 |
+
for day in sales_by_day:
|
278 |
+
date_str = day.date.strftime("%Y-%m-%d") if isinstance(day.date, datetime) else day.date
|
279 |
+
date_range[date_str] = {
|
280 |
+
"order_count": day.order_count,
|
281 |
+
"total_sales": round(day.total_sales, 2) if day.total_sales else 0,
|
282 |
+
}
|
283 |
+
|
284 |
+
# Convert to list format
|
285 |
+
result = []
|
286 |
+
for date_str, data in date_range.items():
|
287 |
+
result.append({
|
288 |
+
"date": date_str,
|
289 |
+
"order_count": data["order_count"],
|
290 |
+
"total_sales": data["total_sales"],
|
291 |
+
})
|
292 |
+
|
293 |
+
return result
|
294 |
+
|
295 |
+
|
296 |
+
# Get chef performance metrics
|
297 |
+
@router.get("/chef-performance")
|
298 |
+
def get_chef_performance(request: Request, days: int = 30, db: Session = Depends(get_session_database)):
|
299 |
+
# Calculate the date range
|
300 |
+
end_date = datetime.now(timezone.utc)
|
301 |
+
start_date = end_date - timedelta(days=days)
|
302 |
+
|
303 |
+
# Get completed orders count and average time to complete
|
304 |
+
completed_orders = (
|
305 |
+
db.query(Order)
|
306 |
+
.filter(Order.status.in_(["completed", "paid"]))
|
307 |
+
.filter(Order.created_at >= start_date)
|
308 |
+
.filter(Order.created_at <= end_date)
|
309 |
+
.all()
|
310 |
+
)
|
311 |
+
|
312 |
+
total_completed = len(completed_orders)
|
313 |
+
|
314 |
+
# Calculate average items per order
|
315 |
+
avg_items_per_order_query = (
|
316 |
+
db.query(
|
317 |
+
func.avg(
|
318 |
+
db.query(func.count(OrderItem.id))
|
319 |
+
.filter(OrderItem.order_id == Order.id)
|
320 |
+
.scalar_subquery()
|
321 |
+
).label("avg_items")
|
322 |
+
)
|
323 |
+
.filter(Order.status.in_(["completed", "paid"]))
|
324 |
+
.filter(Order.created_at >= start_date)
|
325 |
+
.filter(Order.created_at <= end_date)
|
326 |
+
.first()
|
327 |
+
)
|
328 |
+
|
329 |
+
avg_items_per_order = avg_items_per_order_query.avg_items if avg_items_per_order_query.avg_items else 0
|
330 |
+
|
331 |
+
# Get busiest day of week
|
332 |
+
busiest_day_query = (
|
333 |
+
db.query(
|
334 |
+
extract('dow', Order.created_at).label("day_of_week"),
|
335 |
+
func.count(Order.id).label("order_count")
|
336 |
+
)
|
337 |
+
.filter(Order.created_at >= start_date)
|
338 |
+
.filter(Order.created_at <= end_date)
|
339 |
+
.group_by(extract('dow', Order.created_at))
|
340 |
+
.order_by(desc("order_count"))
|
341 |
+
.first()
|
342 |
+
)
|
343 |
+
|
344 |
+
busiest_day = None
|
345 |
+
if busiest_day_query:
|
346 |
+
# Convert day number to day name (0 = Sunday, 1 = Monday, etc.)
|
347 |
+
day_names = list(calendar.day_name)
|
348 |
+
day_number = int(busiest_day_query.day_of_week)
|
349 |
+
busiest_day = day_names[day_number]
|
350 |
+
|
351 |
+
return {
|
352 |
+
"total_completed_orders": total_completed,
|
353 |
+
"avg_items_per_order": round(avg_items_per_order, 2),
|
354 |
+
"busiest_day": busiest_day,
|
355 |
+
}
|
356 |
+
|
357 |
+
|
358 |
+
# Get table utilization statistics
|
359 |
+
@router.get("/table-utilization")
|
360 |
+
def get_table_utilization(request: Request, db: Session = Depends(get_session_database)):
|
361 |
+
# Get all tables
|
362 |
+
tables = db.query(Table).all()
|
363 |
+
|
364 |
+
# Get order count by table
|
365 |
+
table_orders = (
|
366 |
+
db.query(
|
367 |
+
Order.table_number,
|
368 |
+
func.count(Order.id).label("order_count"),
|
369 |
+
func.sum(
|
370 |
+
db.query(func.sum(Dish.price * OrderItem.quantity))
|
371 |
+
.join(OrderItem, Dish.id == OrderItem.dish_id)
|
372 |
+
.filter(OrderItem.order_id == Order.id)
|
373 |
+
.scalar_subquery()
|
374 |
+
).label("total_revenue"),
|
375 |
+
)
|
376 |
+
.group_by(Order.table_number)
|
377 |
+
.all()
|
378 |
+
)
|
379 |
+
|
380 |
+
# Create a dictionary with all tables
|
381 |
+
table_stats = {}
|
382 |
+
for table in tables:
|
383 |
+
table_stats[table.table_number] = {
|
384 |
+
"table_number": table.table_number,
|
385 |
+
"is_occupied": table.is_occupied,
|
386 |
+
"order_count": 0,
|
387 |
+
"total_revenue": 0,
|
388 |
+
}
|
389 |
+
|
390 |
+
# Fill in the actual data
|
391 |
+
for table in table_orders:
|
392 |
+
if table.table_number in table_stats:
|
393 |
+
table_stats[table.table_number]["order_count"] = table.order_count
|
394 |
+
table_stats[table.table_number]["total_revenue"] = round(table.total_revenue, 2) if table.total_revenue else 0
|
395 |
+
|
396 |
+
# Convert to list format
|
397 |
+
result = list(table_stats.values())
|
398 |
+
|
399 |
+
# Sort by order count (descending)
|
400 |
+
result.sort(key=lambda x: x["order_count"], reverse=True)
|
401 |
+
|
402 |
+
return result
|
403 |
+
|
404 |
+
|
405 |
+
# Get customer visit frequency analysis
|
406 |
+
@router.get("/customer-frequency")
|
407 |
+
def get_customer_frequency(
|
408 |
+
request: Request,
|
409 |
+
start_date: str = None,
|
410 |
+
end_date: str = None,
|
411 |
+
db: Session = Depends(get_session_database)
|
412 |
+
):
|
413 |
+
# Parse date strings to datetime objects if provided
|
414 |
+
start_datetime = None
|
415 |
+
end_datetime = None
|
416 |
+
|
417 |
+
if start_date:
|
418 |
+
try:
|
419 |
+
start_datetime = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
420 |
+
except ValueError:
|
421 |
+
raise HTTPException(status_code=400, detail="Invalid start_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
|
422 |
+
|
423 |
+
if end_date:
|
424 |
+
try:
|
425 |
+
end_datetime = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
426 |
+
except ValueError:
|
427 |
+
raise HTTPException(status_code=400, detail="Invalid end_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
|
428 |
+
|
429 |
+
# Get visit count distribution
|
430 |
+
visit_counts_query = db.query(Person.visit_count)
|
431 |
+
|
432 |
+
# Apply date filters if provided
|
433 |
+
if start_datetime or end_datetime:
|
434 |
+
# Get person IDs who placed orders in the date range
|
435 |
+
orders_query = db.query(Order.person_id).distinct()
|
436 |
+
|
437 |
+
if start_datetime:
|
438 |
+
orders_query = orders_query.filter(Order.created_at >= start_datetime)
|
439 |
+
|
440 |
+
if end_datetime:
|
441 |
+
orders_query = orders_query.filter(Order.created_at <= end_datetime)
|
442 |
+
|
443 |
+
person_ids = [result[0] for result in orders_query.all() if result[0] is not None]
|
444 |
+
visit_counts_query = visit_counts_query.filter(Person.id.in_(person_ids))
|
445 |
+
|
446 |
+
visit_counts = visit_counts_query.all()
|
447 |
+
|
448 |
+
# Create frequency buckets
|
449 |
+
frequency_buckets = {
|
450 |
+
"1 visit": 0,
|
451 |
+
"2-3 visits": 0,
|
452 |
+
"4-5 visits": 0,
|
453 |
+
"6-10 visits": 0,
|
454 |
+
"11+ visits": 0,
|
455 |
+
}
|
456 |
+
|
457 |
+
# Fill the buckets
|
458 |
+
for visit in visit_counts:
|
459 |
+
count = visit.visit_count
|
460 |
+
if count == 1:
|
461 |
+
frequency_buckets["1 visit"] += 1
|
462 |
+
elif 2 <= count <= 3:
|
463 |
+
frequency_buckets["2-3 visits"] += 1
|
464 |
+
elif 4 <= count <= 5:
|
465 |
+
frequency_buckets["4-5 visits"] += 1
|
466 |
+
elif 6 <= count <= 10:
|
467 |
+
frequency_buckets["6-10 visits"] += 1
|
468 |
+
else:
|
469 |
+
frequency_buckets["11+ visits"] += 1
|
470 |
+
|
471 |
+
# Convert to list format
|
472 |
+
result = []
|
473 |
+
for bucket, count in frequency_buckets.items():
|
474 |
+
result.append({
|
475 |
+
"frequency": bucket,
|
476 |
+
"customer_count": count,
|
477 |
+
})
|
478 |
+
|
479 |
+
return result
|
480 |
+
|
481 |
+
|
482 |
+
# Get feedback analysis
|
483 |
+
@router.get("/feedback-analysis")
|
484 |
+
def get_feedback_analysis(
|
485 |
+
request: Request,
|
486 |
+
start_date: str = None,
|
487 |
+
end_date: str = None,
|
488 |
+
db: Session = Depends(get_session_database)
|
489 |
+
):
|
490 |
+
# Parse date strings to datetime objects if provided
|
491 |
+
start_datetime = None
|
492 |
+
end_datetime = None
|
493 |
+
|
494 |
+
if start_date:
|
495 |
+
try:
|
496 |
+
start_datetime = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
497 |
+
except ValueError:
|
498 |
+
raise HTTPException(status_code=400, detail="Invalid start_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
|
499 |
+
|
500 |
+
if end_date:
|
501 |
+
try:
|
502 |
+
end_datetime = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
503 |
+
except ValueError:
|
504 |
+
raise HTTPException(status_code=400, detail="Invalid end_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
|
505 |
+
|
506 |
+
# Base query for feedback
|
507 |
+
feedback_query = db.query(Feedback)
|
508 |
+
|
509 |
+
# Apply date filters if provided
|
510 |
+
if start_datetime:
|
511 |
+
feedback_query = feedback_query.filter(Feedback.created_at >= start_datetime)
|
512 |
+
|
513 |
+
if end_datetime:
|
514 |
+
feedback_query = feedback_query.filter(Feedback.created_at <= end_datetime)
|
515 |
+
|
516 |
+
# Get all feedback
|
517 |
+
all_feedback = feedback_query.all()
|
518 |
+
|
519 |
+
# Calculate average rating
|
520 |
+
total_ratings = len(all_feedback)
|
521 |
+
sum_ratings = sum(feedback.rating for feedback in all_feedback)
|
522 |
+
avg_rating = round(sum_ratings / total_ratings, 1) if total_ratings > 0 else 0
|
523 |
+
|
524 |
+
# Count ratings by score
|
525 |
+
rating_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
526 |
+
for feedback in all_feedback:
|
527 |
+
rating_counts[feedback.rating] = rating_counts.get(feedback.rating, 0) + 1
|
528 |
+
|
529 |
+
# Calculate rating percentages
|
530 |
+
rating_percentages = {}
|
531 |
+
for rating, count in rating_counts.items():
|
532 |
+
rating_percentages[rating] = round((count / total_ratings) * 100, 1) if total_ratings > 0 else 0
|
533 |
+
|
534 |
+
# Get recent feedback with comments
|
535 |
+
recent_feedback = (
|
536 |
+
db.query(Feedback, Person.username)
|
537 |
+
.outerjoin(Person, Feedback.person_id == Person.id)
|
538 |
+
.filter(Feedback.comment != None)
|
539 |
+
.filter(Feedback.comment != "")
|
540 |
+
)
|
541 |
+
|
542 |
+
# Apply date filters if provided
|
543 |
+
if start_datetime:
|
544 |
+
recent_feedback = recent_feedback.filter(Feedback.created_at >= start_datetime)
|
545 |
+
|
546 |
+
if end_datetime:
|
547 |
+
recent_feedback = recent_feedback.filter(Feedback.created_at <= end_datetime)
|
548 |
+
|
549 |
+
recent_feedback = recent_feedback.order_by(Feedback.created_at.desc()).limit(10).all()
|
550 |
+
|
551 |
+
# Format recent feedback
|
552 |
+
formatted_feedback = []
|
553 |
+
for feedback, username in recent_feedback:
|
554 |
+
formatted_feedback.append({
|
555 |
+
"id": feedback.id,
|
556 |
+
"rating": feedback.rating,
|
557 |
+
"comment": feedback.comment,
|
558 |
+
"username": username or "Anonymous",
|
559 |
+
"created_at": feedback.created_at.isoformat(),
|
560 |
+
})
|
561 |
+
|
562 |
+
# Return analysis
|
563 |
+
return {
|
564 |
+
"total_feedback": total_ratings,
|
565 |
+
"average_rating": avg_rating,
|
566 |
+
"rating_counts": rating_counts,
|
567 |
+
"rating_percentages": rating_percentages,
|
568 |
+
"recent_comments": formatted_feedback,
|
569 |
+
"date_range": {
|
570 |
+
"start_date": start_date,
|
571 |
+
"end_date": end_date
|
572 |
+
}
|
573 |
+
}
|
app/routers/chef.py
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import List
|
4 |
+
from datetime import datetime, timezone
|
5 |
+
|
6 |
+
from ..database import get_db, Dish, Order, OrderItem, get_session_db, get_hotel_id_from_request
|
7 |
+
from ..models.dish import Dish as DishModel
|
8 |
+
from ..models.order import Order as OrderModel
|
9 |
+
from ..middleware import get_session_id
|
10 |
+
|
11 |
+
router = APIRouter(
|
12 |
+
prefix="/chef",
|
13 |
+
tags=["chef"],
|
14 |
+
responses={404: {"description": "Not found"}},
|
15 |
+
)
|
16 |
+
|
17 |
+
|
18 |
+
# Dependency to get session-aware database
|
19 |
+
def get_session_database(request: Request):
|
20 |
+
session_id = get_session_id(request)
|
21 |
+
return next(get_session_db(session_id))
|
22 |
+
|
23 |
+
# Add an API endpoint to get completed orders count
|
24 |
+
@router.get("/api/completed-orders-count")
|
25 |
+
def get_completed_orders_count(request: Request, db: Session = Depends(get_session_database)):
|
26 |
+
hotel_id = get_hotel_id_from_request(request)
|
27 |
+
completed_orders = db.query(Order).filter(
|
28 |
+
Order.hotel_id == hotel_id,
|
29 |
+
Order.status == "completed"
|
30 |
+
).count()
|
31 |
+
return {"count": completed_orders}
|
32 |
+
|
33 |
+
# Get pending orders (orders that need to be accepted)
|
34 |
+
@router.get("/orders/pending", response_model=List[OrderModel])
|
35 |
+
def get_pending_orders(request: Request, db: Session = Depends(get_session_database)):
|
36 |
+
hotel_id = get_hotel_id_from_request(request)
|
37 |
+
orders = db.query(Order).filter(
|
38 |
+
Order.hotel_id == hotel_id,
|
39 |
+
Order.status == "pending"
|
40 |
+
).all()
|
41 |
+
return orders
|
42 |
+
|
43 |
+
# Get accepted orders (orders that have been accepted but not completed)
|
44 |
+
@router.get("/orders/accepted", response_model=List[OrderModel])
|
45 |
+
def get_accepted_orders(request: Request, db: Session = Depends(get_session_database)):
|
46 |
+
hotel_id = get_hotel_id_from_request(request)
|
47 |
+
orders = db.query(Order).filter(
|
48 |
+
Order.hotel_id == hotel_id,
|
49 |
+
Order.status == "accepted"
|
50 |
+
).all()
|
51 |
+
return orders
|
52 |
+
|
53 |
+
# Accept an order
|
54 |
+
@router.put("/orders/{order_id}/accept")
|
55 |
+
def accept_order(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
56 |
+
hotel_id = get_hotel_id_from_request(request)
|
57 |
+
db_order = db.query(Order).filter(
|
58 |
+
Order.hotel_id == hotel_id,
|
59 |
+
Order.id == order_id
|
60 |
+
).first()
|
61 |
+
if db_order is None:
|
62 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
63 |
+
|
64 |
+
if db_order.status != "pending":
|
65 |
+
raise HTTPException(status_code=400, detail="Order is not in pending status")
|
66 |
+
|
67 |
+
db_order.status = "accepted"
|
68 |
+
db_order.updated_at = datetime.now(timezone.utc)
|
69 |
+
|
70 |
+
db.commit()
|
71 |
+
|
72 |
+
return {"message": "Order accepted successfully"}
|
73 |
+
|
74 |
+
# Mark order as completed (only accepted orders can be completed)
|
75 |
+
@router.put("/orders/{order_id}/complete")
|
76 |
+
def complete_order(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
77 |
+
hotel_id = get_hotel_id_from_request(request)
|
78 |
+
db_order = db.query(Order).filter(
|
79 |
+
Order.hotel_id == hotel_id,
|
80 |
+
Order.id == order_id
|
81 |
+
).first()
|
82 |
+
if db_order is None:
|
83 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
84 |
+
|
85 |
+
if db_order.status != "accepted":
|
86 |
+
raise HTTPException(status_code=400, detail="Order must be accepted before it can be completed")
|
87 |
+
|
88 |
+
db_order.status = "completed"
|
89 |
+
db_order.updated_at = datetime.now(timezone.utc)
|
90 |
+
|
91 |
+
db.commit()
|
92 |
+
|
93 |
+
return {"message": "Order marked as completed"}
|
app/routers/customer.py
ADDED
@@ -0,0 +1,728 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status, Query
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import List, Dict, Any
|
4 |
+
import uuid
|
5 |
+
from datetime import datetime, timezone, timedelta
|
6 |
+
|
7 |
+
from ..database import get_db, Dish, Order, OrderItem, Person, get_session_db, get_hotel_id_from_request
|
8 |
+
from ..models.dish import Dish as DishModel
|
9 |
+
from ..models.order import OrderCreate, Order as OrderModel
|
10 |
+
from ..models.user import (
|
11 |
+
PersonCreate,
|
12 |
+
PersonLogin,
|
13 |
+
Person as PersonModel,
|
14 |
+
PhoneAuthRequest,
|
15 |
+
PhoneVerifyRequest,
|
16 |
+
UsernameRequest
|
17 |
+
)
|
18 |
+
from ..services import firebase_auth
|
19 |
+
from ..middleware import get_session_id
|
20 |
+
|
21 |
+
# Demo mode configuration
|
22 |
+
DEMO_MODE_ENABLED = True # Set to False to disable demo mode
|
23 |
+
DEMO_CUSTOMER_ID = 999999
|
24 |
+
DEMO_CUSTOMER_USERNAME = "Demo Customer"
|
25 |
+
DEMO_CUSTOMER_PHONE = "+91-DEMO-USER"
|
26 |
+
|
27 |
+
router = APIRouter(
|
28 |
+
prefix="/customer",
|
29 |
+
tags=["customer"],
|
30 |
+
responses={404: {"description": "Not found"}},
|
31 |
+
)
|
32 |
+
|
33 |
+
|
34 |
+
# Dependency to get session-aware database
|
35 |
+
def get_session_database(request: Request):
|
36 |
+
session_id = get_session_id(request)
|
37 |
+
return next(get_session_db(session_id))
|
38 |
+
|
39 |
+
|
40 |
+
# Demo mode endpoint - creates or returns demo customer
|
41 |
+
@router.post("/api/demo-login", response_model=Dict[str, Any])
|
42 |
+
def demo_login(request: Request, db: Session = Depends(get_session_database)):
|
43 |
+
"""
|
44 |
+
Demo mode login - bypasses authentication and returns a demo customer
|
45 |
+
"""
|
46 |
+
if not DEMO_MODE_ENABLED:
|
47 |
+
raise HTTPException(
|
48 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
49 |
+
detail="Demo mode is disabled"
|
50 |
+
)
|
51 |
+
|
52 |
+
hotel_id = get_hotel_id_from_request(request)
|
53 |
+
|
54 |
+
# Check if demo customer already exists for this hotel
|
55 |
+
demo_user = db.query(Person).filter(
|
56 |
+
Person.hotel_id == hotel_id,
|
57 |
+
Person.id == DEMO_CUSTOMER_ID
|
58 |
+
).first()
|
59 |
+
|
60 |
+
if not demo_user:
|
61 |
+
# Create demo customer
|
62 |
+
demo_user = Person(
|
63 |
+
id=DEMO_CUSTOMER_ID,
|
64 |
+
hotel_id=hotel_id,
|
65 |
+
username=DEMO_CUSTOMER_USERNAME,
|
66 |
+
password="demo", # Demo password
|
67 |
+
phone_number=DEMO_CUSTOMER_PHONE,
|
68 |
+
visit_count=5, # Show as returning customer
|
69 |
+
last_visit=datetime.now(timezone.utc),
|
70 |
+
created_at=datetime.now(timezone.utc)
|
71 |
+
)
|
72 |
+
db.add(demo_user)
|
73 |
+
db.commit()
|
74 |
+
db.refresh(demo_user)
|
75 |
+
else:
|
76 |
+
# Update last visit time
|
77 |
+
demo_user.last_visit = datetime.now(timezone.utc)
|
78 |
+
db.commit()
|
79 |
+
|
80 |
+
return {
|
81 |
+
"success": True,
|
82 |
+
"message": "Demo login successful",
|
83 |
+
"user_exists": True,
|
84 |
+
"user_id": demo_user.id,
|
85 |
+
"username": demo_user.username,
|
86 |
+
"demo_mode": True
|
87 |
+
}
|
88 |
+
|
89 |
+
|
90 |
+
# Get all dishes for menu (only visible ones)
|
91 |
+
@router.get("/api/menu", response_model=List[DishModel])
|
92 |
+
def get_menu(request: Request, category: str = None, db: Session = Depends(get_session_database)):
|
93 |
+
hotel_id = get_hotel_id_from_request(request)
|
94 |
+
|
95 |
+
if category:
|
96 |
+
# Filter dishes that contain the specified category in their JSON array
|
97 |
+
import json
|
98 |
+
all_dishes = db.query(Dish).filter(
|
99 |
+
Dish.hotel_id == hotel_id,
|
100 |
+
Dish.visibility == 1
|
101 |
+
).all()
|
102 |
+
|
103 |
+
filtered_dishes = []
|
104 |
+
for dish in all_dishes:
|
105 |
+
try:
|
106 |
+
dish_categories = json.loads(dish.category) if dish.category else []
|
107 |
+
if isinstance(dish_categories, list) and category in dish_categories:
|
108 |
+
filtered_dishes.append(dish)
|
109 |
+
elif isinstance(dish_categories, str) and dish_categories == category:
|
110 |
+
filtered_dishes.append(dish)
|
111 |
+
except (json.JSONDecodeError, TypeError):
|
112 |
+
# Backward compatibility: treat as single category
|
113 |
+
if dish.category == category:
|
114 |
+
filtered_dishes.append(dish)
|
115 |
+
|
116 |
+
return filtered_dishes
|
117 |
+
else:
|
118 |
+
dishes = db.query(Dish).filter(
|
119 |
+
Dish.hotel_id == hotel_id,
|
120 |
+
Dish.visibility == 1
|
121 |
+
).all()
|
122 |
+
return dishes
|
123 |
+
|
124 |
+
|
125 |
+
# Get offer dishes (only visible ones)
|
126 |
+
@router.get("/api/offers", response_model=List[DishModel])
|
127 |
+
def get_offers(request: Request, db: Session = Depends(get_session_database)):
|
128 |
+
hotel_id = get_hotel_id_from_request(request)
|
129 |
+
dishes = db.query(Dish).filter(
|
130 |
+
Dish.hotel_id == hotel_id,
|
131 |
+
Dish.is_offer == 1,
|
132 |
+
Dish.visibility == 1
|
133 |
+
).all()
|
134 |
+
return dishes
|
135 |
+
|
136 |
+
|
137 |
+
# Get special dishes (only visible ones)
|
138 |
+
@router.get("/api/specials", response_model=List[DishModel])
|
139 |
+
def get_specials(request: Request, db: Session = Depends(get_session_database)):
|
140 |
+
hotel_id = get_hotel_id_from_request(request)
|
141 |
+
dishes = db.query(Dish).filter(
|
142 |
+
Dish.hotel_id == hotel_id,
|
143 |
+
Dish.is_special == 1,
|
144 |
+
Dish.visibility == 1
|
145 |
+
).all()
|
146 |
+
return dishes
|
147 |
+
|
148 |
+
|
149 |
+
# Get all dish categories (only from visible dishes)
|
150 |
+
@router.get("/api/categories")
|
151 |
+
def get_categories(request: Request, db: Session = Depends(get_session_database)):
|
152 |
+
hotel_id = get_hotel_id_from_request(request)
|
153 |
+
categories = db.query(Dish.category).filter(
|
154 |
+
Dish.hotel_id == hotel_id,
|
155 |
+
Dish.visibility == 1
|
156 |
+
).distinct().all()
|
157 |
+
|
158 |
+
# Parse JSON categories and flatten them
|
159 |
+
import json
|
160 |
+
unique_categories = set()
|
161 |
+
|
162 |
+
for category_tuple in categories:
|
163 |
+
category_str = category_tuple[0]
|
164 |
+
if category_str:
|
165 |
+
try:
|
166 |
+
# Try to parse as JSON array
|
167 |
+
category_list = json.loads(category_str)
|
168 |
+
if isinstance(category_list, list):
|
169 |
+
unique_categories.update(category_list)
|
170 |
+
else:
|
171 |
+
unique_categories.add(category_str)
|
172 |
+
except (json.JSONDecodeError, TypeError):
|
173 |
+
# If not JSON, treat as single category
|
174 |
+
unique_categories.add(category_str)
|
175 |
+
|
176 |
+
return sorted(list(unique_categories))
|
177 |
+
|
178 |
+
|
179 |
+
# Register a new user or update existing user
|
180 |
+
@router.post("/api/register", response_model=PersonModel)
|
181 |
+
def register_user(user: PersonCreate, request: Request, db: Session = Depends(get_session_database)):
|
182 |
+
hotel_id = get_hotel_id_from_request(request)
|
183 |
+
|
184 |
+
# Check if user already exists for this hotel
|
185 |
+
db_user = db.query(Person).filter(
|
186 |
+
Person.hotel_id == hotel_id,
|
187 |
+
Person.username == user.username
|
188 |
+
).first()
|
189 |
+
|
190 |
+
if db_user:
|
191 |
+
# Update existing user's last visit time (visit count updated only when order is placed)
|
192 |
+
db_user.last_visit = datetime.now(timezone.utc)
|
193 |
+
db.commit()
|
194 |
+
db.refresh(db_user)
|
195 |
+
return db_user
|
196 |
+
else:
|
197 |
+
# Create new user (visit count will be incremented when first order is placed)
|
198 |
+
db_user = Person(
|
199 |
+
hotel_id=hotel_id,
|
200 |
+
username=user.username,
|
201 |
+
password=user.password, # In a real app, you should hash this password
|
202 |
+
visit_count=0,
|
203 |
+
last_visit=datetime.now(timezone.utc),
|
204 |
+
)
|
205 |
+
db.add(db_user)
|
206 |
+
db.commit()
|
207 |
+
db.refresh(db_user)
|
208 |
+
return db_user
|
209 |
+
|
210 |
+
|
211 |
+
# Login user
|
212 |
+
@router.post("/api/login", response_model=Dict[str, Any])
|
213 |
+
def login_user(user_data: PersonLogin, request: Request, db: Session = Depends(get_session_database)):
|
214 |
+
hotel_id = get_hotel_id_from_request(request)
|
215 |
+
|
216 |
+
# Find user by username for this hotel
|
217 |
+
db_user = db.query(Person).filter(
|
218 |
+
Person.hotel_id == hotel_id,
|
219 |
+
Person.username == user_data.username
|
220 |
+
).first()
|
221 |
+
|
222 |
+
if not db_user:
|
223 |
+
raise HTTPException(
|
224 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username"
|
225 |
+
)
|
226 |
+
|
227 |
+
# Check password (in a real app, you would verify hashed passwords)
|
228 |
+
if db_user.password != user_data.password:
|
229 |
+
raise HTTPException(
|
230 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password"
|
231 |
+
)
|
232 |
+
|
233 |
+
# Update last visit time (but not visit count - that's only updated when order is placed)
|
234 |
+
db_user.last_visit = datetime.now(timezone.utc)
|
235 |
+
db.commit()
|
236 |
+
|
237 |
+
# Return user info and a success message
|
238 |
+
return {
|
239 |
+
"user": {
|
240 |
+
"id": db_user.id,
|
241 |
+
"username": db_user.username,
|
242 |
+
"visit_count": db_user.visit_count,
|
243 |
+
},
|
244 |
+
"message": "Login successful",
|
245 |
+
}
|
246 |
+
|
247 |
+
|
248 |
+
# Create new order
|
249 |
+
@router.post("/api/orders", response_model=OrderModel)
|
250 |
+
def create_order(
|
251 |
+
order: OrderCreate, request: Request, person_id: int = Query(None), db: Session = Depends(get_session_database)
|
252 |
+
):
|
253 |
+
hotel_id = get_hotel_id_from_request(request)
|
254 |
+
|
255 |
+
# If person_id is not provided but we have a username/password, try to find or create the user
|
256 |
+
if not person_id and hasattr(order, "username") and hasattr(order, "password"):
|
257 |
+
# Check if user exists for this hotel
|
258 |
+
db_user = db.query(Person).filter(
|
259 |
+
Person.hotel_id == hotel_id,
|
260 |
+
Person.username == order.username
|
261 |
+
).first()
|
262 |
+
|
263 |
+
if db_user:
|
264 |
+
# Update existing user's visit count
|
265 |
+
db_user.visit_count += 1
|
266 |
+
db_user.last_visit = datetime.now(timezone.utc)
|
267 |
+
db.commit()
|
268 |
+
person_id = db_user.id
|
269 |
+
else:
|
270 |
+
# Create new user (visit count starts at 1 since they're placing their first order)
|
271 |
+
db_user = Person(
|
272 |
+
hotel_id=hotel_id,
|
273 |
+
username=order.username,
|
274 |
+
password=order.password,
|
275 |
+
visit_count=1,
|
276 |
+
last_visit=datetime.now(timezone.utc),
|
277 |
+
)
|
278 |
+
db.add(db_user)
|
279 |
+
db.commit()
|
280 |
+
db.refresh(db_user)
|
281 |
+
person_id = db_user.id
|
282 |
+
elif person_id:
|
283 |
+
# If person_id is provided (normal flow), increment visit count for that user
|
284 |
+
db_user = db.query(Person).filter(
|
285 |
+
Person.hotel_id == hotel_id,
|
286 |
+
Person.id == person_id
|
287 |
+
).first()
|
288 |
+
if db_user:
|
289 |
+
db_user.visit_count += 1
|
290 |
+
db_user.last_visit = datetime.now(timezone.utc)
|
291 |
+
db.commit()
|
292 |
+
|
293 |
+
# Create order
|
294 |
+
db_order = Order(
|
295 |
+
hotel_id=hotel_id,
|
296 |
+
table_number=order.table_number,
|
297 |
+
unique_id=order.unique_id,
|
298 |
+
person_id=person_id, # Link order to person if provided
|
299 |
+
status="pending",
|
300 |
+
)
|
301 |
+
db.add(db_order)
|
302 |
+
db.commit()
|
303 |
+
db.refresh(db_order)
|
304 |
+
|
305 |
+
# Mark the table as occupied
|
306 |
+
from ..database import Table
|
307 |
+
|
308 |
+
db_table = db.query(Table).filter(
|
309 |
+
Table.hotel_id == hotel_id,
|
310 |
+
Table.table_number == order.table_number
|
311 |
+
).first()
|
312 |
+
if db_table:
|
313 |
+
db_table.is_occupied = True
|
314 |
+
db_table.current_order_id = db_order.id
|
315 |
+
db.commit()
|
316 |
+
|
317 |
+
# Create order items
|
318 |
+
for item in order.items:
|
319 |
+
# Get the dish to include its information and verify it belongs to this hotel
|
320 |
+
dish = db.query(Dish).filter(
|
321 |
+
Dish.hotel_id == hotel_id,
|
322 |
+
Dish.id == item.dish_id
|
323 |
+
).first()
|
324 |
+
if not dish:
|
325 |
+
continue # Skip if dish doesn't exist or doesn't belong to this hotel
|
326 |
+
|
327 |
+
db_item = OrderItem(
|
328 |
+
hotel_id=hotel_id,
|
329 |
+
order_id=db_order.id,
|
330 |
+
dish_id=item.dish_id,
|
331 |
+
quantity=item.quantity,
|
332 |
+
price=dish.price, # Store price at time of order
|
333 |
+
remarks=item.remarks,
|
334 |
+
)
|
335 |
+
db.add(db_item)
|
336 |
+
|
337 |
+
db.commit()
|
338 |
+
db.refresh(db_order)
|
339 |
+
|
340 |
+
return db_order
|
341 |
+
|
342 |
+
|
343 |
+
# Get order status
|
344 |
+
@router.get("/api/orders/{order_id}", response_model=OrderModel)
|
345 |
+
def get_order(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
346 |
+
hotel_id = get_hotel_id_from_request(request)
|
347 |
+
|
348 |
+
# Use joinedload to load the dish relationship for each order item
|
349 |
+
order = db.query(Order).filter(
|
350 |
+
Order.hotel_id == hotel_id,
|
351 |
+
Order.id == order_id
|
352 |
+
).first()
|
353 |
+
if order is None:
|
354 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
355 |
+
|
356 |
+
# Explicitly load dish information for each order item
|
357 |
+
for item in order.items:
|
358 |
+
if not hasattr(item, "dish") or item.dish is None:
|
359 |
+
dish = db.query(Dish).filter(
|
360 |
+
Dish.hotel_id == hotel_id,
|
361 |
+
Dish.id == item.dish_id
|
362 |
+
).first()
|
363 |
+
if dish:
|
364 |
+
item.dish = dish
|
365 |
+
|
366 |
+
return order
|
367 |
+
|
368 |
+
|
369 |
+
# Get orders by person_id
|
370 |
+
@router.get("/api/person/{person_id}/orders", response_model=List[OrderModel])
|
371 |
+
def get_person_orders(person_id: int, request: Request, db: Session = Depends(get_session_database)):
|
372 |
+
hotel_id = get_hotel_id_from_request(request)
|
373 |
+
|
374 |
+
# Get all orders for a specific person in this hotel
|
375 |
+
orders = (
|
376 |
+
db.query(Order)
|
377 |
+
.filter(
|
378 |
+
Order.hotel_id == hotel_id,
|
379 |
+
Order.person_id == person_id
|
380 |
+
)
|
381 |
+
.order_by(Order.created_at.desc())
|
382 |
+
.all()
|
383 |
+
)
|
384 |
+
|
385 |
+
# Explicitly load dish information for each order item
|
386 |
+
for order in orders:
|
387 |
+
for item in order.items:
|
388 |
+
if not hasattr(item, "dish") or item.dish is None:
|
389 |
+
dish = db.query(Dish).filter(
|
390 |
+
Dish.hotel_id == hotel_id,
|
391 |
+
Dish.id == item.dish_id
|
392 |
+
).first()
|
393 |
+
if dish:
|
394 |
+
item.dish = dish
|
395 |
+
|
396 |
+
return orders
|
397 |
+
|
398 |
+
|
399 |
+
# Request payment for order
|
400 |
+
@router.put("/api/orders/{order_id}/payment")
|
401 |
+
def request_payment(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
402 |
+
hotel_id = get_hotel_id_from_request(request)
|
403 |
+
|
404 |
+
try:
|
405 |
+
# Check if order exists and is not already paid
|
406 |
+
db_order = db.query(Order).filter(
|
407 |
+
Order.hotel_id == hotel_id,
|
408 |
+
Order.id == order_id
|
409 |
+
).first()
|
410 |
+
if db_order is None:
|
411 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
412 |
+
|
413 |
+
# Check if order is already paid
|
414 |
+
if db_order.status == "paid":
|
415 |
+
return {"message": "Order is already paid"}
|
416 |
+
|
417 |
+
# Check if order is completed (ready for payment)
|
418 |
+
if db_order.status != "completed":
|
419 |
+
raise HTTPException(
|
420 |
+
status_code=400,
|
421 |
+
detail="Order must be completed before payment can be processed"
|
422 |
+
)
|
423 |
+
|
424 |
+
# Calculate order totals and apply discounts
|
425 |
+
from ..database import LoyaltyProgram, SelectionOffer, Person
|
426 |
+
|
427 |
+
# Calculate subtotal from order items
|
428 |
+
subtotal = 0
|
429 |
+
for item in db_order.items:
|
430 |
+
if item.dish:
|
431 |
+
subtotal += item.dish.price * item.quantity
|
432 |
+
|
433 |
+
# Initialize discount amounts
|
434 |
+
loyalty_discount_amount = 0
|
435 |
+
loyalty_discount_percentage = 0
|
436 |
+
selection_offer_discount_amount = 0
|
437 |
+
|
438 |
+
# Apply loyalty discount if customer is registered
|
439 |
+
if db_order.person_id:
|
440 |
+
person = db.query(Person).filter(Person.id == db_order.person_id).first()
|
441 |
+
if person:
|
442 |
+
# Get applicable loyalty discount
|
443 |
+
loyalty_tier = (
|
444 |
+
db.query(LoyaltyProgram)
|
445 |
+
.filter(
|
446 |
+
LoyaltyProgram.hotel_id == hotel_id,
|
447 |
+
LoyaltyProgram.visit_count == person.visit_count,
|
448 |
+
LoyaltyProgram.is_active == True,
|
449 |
+
)
|
450 |
+
.first()
|
451 |
+
)
|
452 |
+
|
453 |
+
if loyalty_tier:
|
454 |
+
loyalty_discount_percentage = loyalty_tier.discount_percentage
|
455 |
+
loyalty_discount_amount = subtotal * (loyalty_discount_percentage / 100)
|
456 |
+
|
457 |
+
# Apply selection offer discount
|
458 |
+
selection_offer = (
|
459 |
+
db.query(SelectionOffer)
|
460 |
+
.filter(
|
461 |
+
SelectionOffer.hotel_id == hotel_id,
|
462 |
+
SelectionOffer.min_amount <= subtotal,
|
463 |
+
SelectionOffer.is_active == True,
|
464 |
+
)
|
465 |
+
.order_by(SelectionOffer.min_amount.desc())
|
466 |
+
.first()
|
467 |
+
)
|
468 |
+
|
469 |
+
if selection_offer:
|
470 |
+
selection_offer_discount_amount = selection_offer.discount_amount
|
471 |
+
|
472 |
+
# Calculate final total after discounts
|
473 |
+
final_total = subtotal - loyalty_discount_amount - selection_offer_discount_amount
|
474 |
+
|
475 |
+
# Ensure final total is not negative
|
476 |
+
final_total = max(0, final_total)
|
477 |
+
|
478 |
+
# Update order with calculated amounts
|
479 |
+
db_order.status = "paid"
|
480 |
+
db_order.subtotal_amount = subtotal
|
481 |
+
db_order.loyalty_discount_amount = loyalty_discount_amount
|
482 |
+
db_order.loyalty_discount_percentage = loyalty_discount_percentage
|
483 |
+
db_order.selection_offer_discount_amount = selection_offer_discount_amount
|
484 |
+
db_order.total_amount = final_total
|
485 |
+
db_order.updated_at = datetime.now(timezone.utc)
|
486 |
+
|
487 |
+
# Check if this is the last unpaid order for this table
|
488 |
+
from ..database import Table
|
489 |
+
|
490 |
+
# Get all orders for this table that are not paid
|
491 |
+
table_unpaid_orders = db.query(Order).filter(
|
492 |
+
Order.table_number == db_order.table_number,
|
493 |
+
Order.status != "paid",
|
494 |
+
Order.status != "cancelled"
|
495 |
+
).all()
|
496 |
+
|
497 |
+
# If this is the only unpaid order, mark table as free
|
498 |
+
if len(table_unpaid_orders) == 1 and table_unpaid_orders[0].id == order_id:
|
499 |
+
db_table = db.query(Table).filter(Table.table_number == db_order.table_number).first()
|
500 |
+
if db_table:
|
501 |
+
db_table.is_occupied = False
|
502 |
+
db_table.current_order_id = None
|
503 |
+
db_table.updated_at = datetime.now(timezone.utc)
|
504 |
+
|
505 |
+
# Commit the transaction
|
506 |
+
db.commit()
|
507 |
+
db.refresh(db_order)
|
508 |
+
|
509 |
+
return {"message": "Payment completed successfully", "order_id": order_id}
|
510 |
+
|
511 |
+
except HTTPException:
|
512 |
+
# Re-raise HTTP exceptions
|
513 |
+
db.rollback()
|
514 |
+
raise
|
515 |
+
except Exception as e:
|
516 |
+
# Handle any other exceptions
|
517 |
+
db.rollback()
|
518 |
+
print(f"Error processing payment for order {order_id}: {str(e)}")
|
519 |
+
raise HTTPException(
|
520 |
+
status_code=500,
|
521 |
+
detail=f"Error processing payment: {str(e)}"
|
522 |
+
)
|
523 |
+
|
524 |
+
|
525 |
+
# Cancel order
|
526 |
+
@router.put("/api/orders/{order_id}/cancel")
|
527 |
+
def cancel_order(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
528 |
+
hotel_id = get_hotel_id_from_request(request)
|
529 |
+
|
530 |
+
db_order = db.query(Order).filter(
|
531 |
+
Order.hotel_id == hotel_id,
|
532 |
+
Order.id == order_id
|
533 |
+
).first()
|
534 |
+
if db_order is None:
|
535 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
536 |
+
|
537 |
+
# Check if order is in pending status (not accepted or completed)
|
538 |
+
if db_order.status != "pending":
|
539 |
+
raise HTTPException(
|
540 |
+
status_code=400,
|
541 |
+
detail="Only pending orders can be cancelled. Orders that have been accepted by the chef cannot be cancelled."
|
542 |
+
)
|
543 |
+
|
544 |
+
# Update order status to cancelled
|
545 |
+
current_time = datetime.now(timezone.utc)
|
546 |
+
db_order.status = "cancelled"
|
547 |
+
db_order.updated_at = current_time
|
548 |
+
|
549 |
+
# Mark the table as free if this was the current order
|
550 |
+
from ..database import Table
|
551 |
+
|
552 |
+
db_table = db.query(Table).filter(Table.table_number == db_order.table_number).first()
|
553 |
+
if db_table and db_table.current_order_id == db_order.id:
|
554 |
+
db_table.is_occupied = False
|
555 |
+
db_table.current_order_id = None
|
556 |
+
db_table.updated_at = current_time
|
557 |
+
|
558 |
+
db.commit()
|
559 |
+
|
560 |
+
return {"message": "Order cancelled successfully"}
|
561 |
+
|
562 |
+
|
563 |
+
# Get person details
|
564 |
+
@router.get("/api/person/{person_id}", response_model=PersonModel)
|
565 |
+
def get_person(person_id: int, request: Request, db: Session = Depends(get_session_database)):
|
566 |
+
person = db.query(Person).filter(Person.id == person_id).first()
|
567 |
+
if not person:
|
568 |
+
raise HTTPException(status_code=404, detail="Person not found")
|
569 |
+
return person
|
570 |
+
|
571 |
+
|
572 |
+
# Phone authentication endpoints
|
573 |
+
@router.post("/api/phone-auth", response_model=Dict[str, Any])
|
574 |
+
def phone_auth(auth_request: PhoneAuthRequest, request: Request, db: Session = Depends(get_session_database)):
|
575 |
+
"""
|
576 |
+
Initiate phone authentication by sending OTP
|
577 |
+
"""
|
578 |
+
try:
|
579 |
+
# Validate phone number format
|
580 |
+
if not auth_request.phone_number.startswith("+91"):
|
581 |
+
raise HTTPException(
|
582 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
583 |
+
detail="Phone number must start with +91"
|
584 |
+
)
|
585 |
+
|
586 |
+
# Send OTP via Firebase
|
587 |
+
result = firebase_auth.verify_phone_number(auth_request.phone_number)
|
588 |
+
|
589 |
+
print(f"Phone auth initiated for: {auth_request.phone_number}, table: {auth_request.table_number}")
|
590 |
+
|
591 |
+
return {
|
592 |
+
"success": True,
|
593 |
+
"message": "Verification code sent successfully",
|
594 |
+
"session_info": result.get("sessionInfo", "firebase-verification-token")
|
595 |
+
}
|
596 |
+
except HTTPException as e:
|
597 |
+
print(f"HTTP Exception in phone_auth: {e.detail}")
|
598 |
+
raise e
|
599 |
+
except Exception as e:
|
600 |
+
print(f"Exception in phone_auth: {str(e)}")
|
601 |
+
raise HTTPException(
|
602 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
603 |
+
detail=f"Failed to send verification code: {str(e)}"
|
604 |
+
)
|
605 |
+
|
606 |
+
|
607 |
+
@router.post("/api/verify-otp", response_model=Dict[str, Any])
|
608 |
+
def verify_otp(verify_request: PhoneVerifyRequest, request: Request, db: Session = Depends(get_session_database)):
|
609 |
+
"""
|
610 |
+
Verify OTP and authenticate user
|
611 |
+
"""
|
612 |
+
try:
|
613 |
+
print(f"Verifying OTP for phone: {verify_request.phone_number}")
|
614 |
+
|
615 |
+
# Verify OTP via Firebase
|
616 |
+
# Note: The actual OTP verification is done on the client side with Firebase
|
617 |
+
# This is just a validation step
|
618 |
+
firebase_auth.verify_otp(
|
619 |
+
verify_request.phone_number,
|
620 |
+
verify_request.verification_code
|
621 |
+
)
|
622 |
+
|
623 |
+
# Check if user exists in database for this hotel
|
624 |
+
hotel_id = get_hotel_id_from_request(request)
|
625 |
+
user = db.query(Person).filter(
|
626 |
+
Person.hotel_id == hotel_id,
|
627 |
+
Person.phone_number == verify_request.phone_number
|
628 |
+
).first()
|
629 |
+
|
630 |
+
if user:
|
631 |
+
print(f"Existing user found: {user.username}")
|
632 |
+
# Existing user - update last visit time (visit count updated only when order is placed)
|
633 |
+
user.last_visit = datetime.now(timezone.utc)
|
634 |
+
db.commit()
|
635 |
+
db.refresh(user)
|
636 |
+
|
637 |
+
return {
|
638 |
+
"success": True,
|
639 |
+
"message": "Authentication successful",
|
640 |
+
"user_exists": True,
|
641 |
+
"user_id": user.id,
|
642 |
+
"username": user.username
|
643 |
+
}
|
644 |
+
else:
|
645 |
+
print(f"New user with phone: {verify_request.phone_number}")
|
646 |
+
# New user - return flag to collect username
|
647 |
+
return {
|
648 |
+
"success": True,
|
649 |
+
"message": "Authentication successful, but user not found",
|
650 |
+
"user_exists": False
|
651 |
+
}
|
652 |
+
|
653 |
+
except HTTPException as e:
|
654 |
+
print(f"HTTP Exception in verify_otp: {e.detail}")
|
655 |
+
raise e
|
656 |
+
except Exception as e:
|
657 |
+
print(f"Exception in verify_otp: {str(e)}")
|
658 |
+
raise HTTPException(
|
659 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
660 |
+
detail=f"Failed to verify OTP: {str(e)}"
|
661 |
+
)
|
662 |
+
|
663 |
+
|
664 |
+
@router.post("/api/register-phone-user", response_model=Dict[str, Any])
|
665 |
+
def register_phone_user(user_request: UsernameRequest, request: Request, db: Session = Depends(get_session_database)):
|
666 |
+
"""
|
667 |
+
Register a new user after phone authentication
|
668 |
+
"""
|
669 |
+
try:
|
670 |
+
hotel_id = get_hotel_id_from_request(request)
|
671 |
+
print(f"Registering new user with phone: {user_request.phone_number}, username: {user_request.username}")
|
672 |
+
|
673 |
+
# Check if username already exists for this hotel
|
674 |
+
existing_user = db.query(Person).filter(
|
675 |
+
Person.hotel_id == hotel_id,
|
676 |
+
Person.username == user_request.username
|
677 |
+
).first()
|
678 |
+
if existing_user:
|
679 |
+
print(f"Username already exists: {user_request.username}")
|
680 |
+
raise HTTPException(
|
681 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
682 |
+
detail="Username already exists"
|
683 |
+
)
|
684 |
+
|
685 |
+
# Check if phone number already exists for this hotel
|
686 |
+
phone_user = db.query(Person).filter(
|
687 |
+
Person.hotel_id == hotel_id,
|
688 |
+
Person.phone_number == user_request.phone_number
|
689 |
+
).first()
|
690 |
+
if phone_user:
|
691 |
+
print(f"Phone number already registered: {user_request.phone_number}")
|
692 |
+
raise HTTPException(
|
693 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
694 |
+
detail="Phone number already registered"
|
695 |
+
)
|
696 |
+
|
697 |
+
# Create new user (visit count will be incremented when first order is placed)
|
698 |
+
new_user = Person(
|
699 |
+
hotel_id=hotel_id,
|
700 |
+
username=user_request.username,
|
701 |
+
password="", # No password needed for phone auth
|
702 |
+
phone_number=user_request.phone_number,
|
703 |
+
visit_count=0,
|
704 |
+
last_visit=datetime.now(timezone.utc)
|
705 |
+
)
|
706 |
+
|
707 |
+
db.add(new_user)
|
708 |
+
db.commit()
|
709 |
+
db.refresh(new_user)
|
710 |
+
|
711 |
+
print(f"User registered successfully: {new_user.id}, {new_user.username}")
|
712 |
+
|
713 |
+
return {
|
714 |
+
"success": True,
|
715 |
+
"message": "User registered successfully",
|
716 |
+
"user_id": new_user.id,
|
717 |
+
"username": new_user.username
|
718 |
+
}
|
719 |
+
|
720 |
+
except HTTPException as e:
|
721 |
+
print(f"HTTP Exception in register_phone_user: {e.detail}")
|
722 |
+
raise e
|
723 |
+
except Exception as e:
|
724 |
+
print(f"Exception in register_phone_user: {str(e)}")
|
725 |
+
raise HTTPException(
|
726 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
727 |
+
detail=f"Failed to register user: {str(e)}"
|
728 |
+
)
|
app/routers/feedback.py
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import List
|
4 |
+
from datetime import datetime, timezone
|
5 |
+
|
6 |
+
from ..database import get_db, Feedback as FeedbackModel, Order, Person, get_session_db, get_hotel_id_from_request
|
7 |
+
from ..models.feedback import Feedback, FeedbackCreate
|
8 |
+
from ..middleware import get_session_id
|
9 |
+
|
10 |
+
router = APIRouter(
|
11 |
+
prefix="/feedback",
|
12 |
+
tags=["feedback"],
|
13 |
+
responses={404: {"description": "Not found"}},
|
14 |
+
)
|
15 |
+
|
16 |
+
|
17 |
+
# Dependency to get session-aware database
|
18 |
+
def get_session_database(request: Request):
|
19 |
+
session_id = get_session_id(request)
|
20 |
+
return next(get_session_db(session_id))
|
21 |
+
|
22 |
+
|
23 |
+
# Create new feedback
|
24 |
+
@router.post("/", response_model=Feedback)
|
25 |
+
def create_feedback(feedback: FeedbackCreate, request: Request, db: Session = Depends(get_session_database)):
|
26 |
+
hotel_id = get_hotel_id_from_request(request)
|
27 |
+
|
28 |
+
# Check if order exists for this hotel
|
29 |
+
db_order = db.query(Order).filter(
|
30 |
+
Order.hotel_id == hotel_id,
|
31 |
+
Order.id == feedback.order_id
|
32 |
+
).first()
|
33 |
+
if not db_order:
|
34 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
35 |
+
|
36 |
+
# Check if person exists if person_id is provided
|
37 |
+
if feedback.person_id:
|
38 |
+
db_person = db.query(Person).filter(
|
39 |
+
Person.hotel_id == hotel_id,
|
40 |
+
Person.id == feedback.person_id
|
41 |
+
).first()
|
42 |
+
if not db_person:
|
43 |
+
raise HTTPException(status_code=404, detail="Person not found")
|
44 |
+
|
45 |
+
# Create feedback
|
46 |
+
db_feedback = FeedbackModel(
|
47 |
+
hotel_id=hotel_id,
|
48 |
+
order_id=feedback.order_id,
|
49 |
+
person_id=feedback.person_id,
|
50 |
+
rating=feedback.rating,
|
51 |
+
comment=feedback.comment,
|
52 |
+
created_at=datetime.now(timezone.utc),
|
53 |
+
)
|
54 |
+
db.add(db_feedback)
|
55 |
+
db.commit()
|
56 |
+
db.refresh(db_feedback)
|
57 |
+
return db_feedback
|
58 |
+
|
59 |
+
|
60 |
+
# Get all feedback
|
61 |
+
@router.get("/", response_model=List[Feedback])
|
62 |
+
def get_all_feedback(request: Request, db: Session = Depends(get_session_database)):
|
63 |
+
hotel_id = get_hotel_id_from_request(request)
|
64 |
+
return db.query(FeedbackModel).filter(FeedbackModel.hotel_id == hotel_id).all()
|
65 |
+
|
66 |
+
|
67 |
+
# Get feedback by order_id
|
68 |
+
@router.get("/order/{order_id}", response_model=Feedback)
|
69 |
+
def get_feedback_by_order(order_id: int, request: Request, db: Session = Depends(get_session_database)):
|
70 |
+
hotel_id = get_hotel_id_from_request(request)
|
71 |
+
db_feedback = db.query(FeedbackModel).filter(
|
72 |
+
FeedbackModel.hotel_id == hotel_id,
|
73 |
+
FeedbackModel.order_id == order_id
|
74 |
+
).first()
|
75 |
+
if not db_feedback:
|
76 |
+
raise HTTPException(status_code=404, detail="Feedback not found")
|
77 |
+
return db_feedback
|
78 |
+
|
79 |
+
|
80 |
+
# Get feedback by person_id
|
81 |
+
@router.get("/person/{person_id}", response_model=List[Feedback])
|
82 |
+
def get_feedback_by_person(person_id: int, request: Request, db: Session = Depends(get_session_database)):
|
83 |
+
hotel_id = get_hotel_id_from_request(request)
|
84 |
+
return db.query(FeedbackModel).filter(
|
85 |
+
FeedbackModel.hotel_id == hotel_id,
|
86 |
+
FeedbackModel.person_id == person_id
|
87 |
+
).all()
|
app/routers/loyalty.py
ADDED
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import List
|
4 |
+
from datetime import datetime, timezone
|
5 |
+
|
6 |
+
from ..database import get_db, LoyaltyProgram as LoyaltyProgramModel, get_session_db, get_hotel_id_from_request
|
7 |
+
from ..models.loyalty import LoyaltyProgram, LoyaltyProgramCreate, LoyaltyProgramUpdate
|
8 |
+
from ..middleware import get_session_id
|
9 |
+
|
10 |
+
router = APIRouter(
|
11 |
+
prefix="/loyalty",
|
12 |
+
tags=["loyalty"],
|
13 |
+
responses={404: {"description": "Not found"}},
|
14 |
+
)
|
15 |
+
|
16 |
+
|
17 |
+
# Dependency to get session-aware database
|
18 |
+
def get_session_database(request: Request):
|
19 |
+
session_id = get_session_id(request)
|
20 |
+
return next(get_session_db(session_id))
|
21 |
+
|
22 |
+
|
23 |
+
# Get all loyalty program tiers
|
24 |
+
@router.get("/", response_model=List[LoyaltyProgram])
|
25 |
+
def get_all_loyalty_tiers(request: Request, db: Session = Depends(get_session_database)):
|
26 |
+
hotel_id = get_hotel_id_from_request(request)
|
27 |
+
return db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.hotel_id == hotel_id).order_by(LoyaltyProgramModel.visit_count).all()
|
28 |
+
|
29 |
+
|
30 |
+
# Get active loyalty program tiers
|
31 |
+
@router.get("/active", response_model=List[LoyaltyProgram])
|
32 |
+
def get_active_loyalty_tiers(request: Request, db: Session = Depends(get_session_database)):
|
33 |
+
hotel_id = get_hotel_id_from_request(request)
|
34 |
+
return (
|
35 |
+
db.query(LoyaltyProgramModel)
|
36 |
+
.filter(
|
37 |
+
LoyaltyProgramModel.hotel_id == hotel_id,
|
38 |
+
LoyaltyProgramModel.is_active == True
|
39 |
+
)
|
40 |
+
.order_by(LoyaltyProgramModel.visit_count)
|
41 |
+
.all()
|
42 |
+
)
|
43 |
+
|
44 |
+
|
45 |
+
# Get loyalty tier by ID
|
46 |
+
@router.get("/{tier_id}", response_model=LoyaltyProgram)
|
47 |
+
def get_loyalty_tier(tier_id: int, request: Request, db: Session = Depends(get_session_database)):
|
48 |
+
db_tier = (
|
49 |
+
db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.id == tier_id).first()
|
50 |
+
)
|
51 |
+
if not db_tier:
|
52 |
+
raise HTTPException(status_code=404, detail="Loyalty tier not found")
|
53 |
+
return db_tier
|
54 |
+
|
55 |
+
|
56 |
+
# Create new loyalty tier
|
57 |
+
@router.post("/", response_model=LoyaltyProgram)
|
58 |
+
def create_loyalty_tier(tier: LoyaltyProgramCreate, request: Request, db: Session = Depends(get_session_database)):
|
59 |
+
hotel_id = get_hotel_id_from_request(request)
|
60 |
+
|
61 |
+
# Check if a tier with this visit count already exists for this hotel
|
62 |
+
existing_tier = (
|
63 |
+
db.query(LoyaltyProgramModel)
|
64 |
+
.filter(
|
65 |
+
LoyaltyProgramModel.hotel_id == hotel_id,
|
66 |
+
LoyaltyProgramModel.visit_count == tier.visit_count
|
67 |
+
)
|
68 |
+
.first()
|
69 |
+
)
|
70 |
+
if existing_tier:
|
71 |
+
raise HTTPException(
|
72 |
+
status_code=400,
|
73 |
+
detail=f"Loyalty tier with visit count {tier.visit_count} already exists",
|
74 |
+
)
|
75 |
+
|
76 |
+
# Create new tier
|
77 |
+
db_tier = LoyaltyProgramModel(
|
78 |
+
hotel_id=hotel_id,
|
79 |
+
visit_count=tier.visit_count,
|
80 |
+
discount_percentage=tier.discount_percentage,
|
81 |
+
is_active=tier.is_active,
|
82 |
+
created_at=datetime.now(timezone.utc),
|
83 |
+
updated_at=datetime.now(timezone.utc),
|
84 |
+
)
|
85 |
+
db.add(db_tier)
|
86 |
+
db.commit()
|
87 |
+
db.refresh(db_tier)
|
88 |
+
return db_tier
|
89 |
+
|
90 |
+
|
91 |
+
# Update loyalty tier
|
92 |
+
@router.put("/{tier_id}", response_model=LoyaltyProgram)
|
93 |
+
def update_loyalty_tier(
|
94 |
+
tier_id: int, tier_update: LoyaltyProgramUpdate, request: Request, db: Session = Depends(get_session_database)
|
95 |
+
):
|
96 |
+
db_tier = (
|
97 |
+
db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.id == tier_id).first()
|
98 |
+
)
|
99 |
+
if not db_tier:
|
100 |
+
raise HTTPException(status_code=404, detail="Loyalty tier not found")
|
101 |
+
|
102 |
+
# Check if updating visit count and if it already exists
|
103 |
+
if (
|
104 |
+
tier_update.visit_count is not None
|
105 |
+
and tier_update.visit_count != db_tier.visit_count
|
106 |
+
):
|
107 |
+
existing_tier = (
|
108 |
+
db.query(LoyaltyProgramModel)
|
109 |
+
.filter(
|
110 |
+
LoyaltyProgramModel.visit_count == tier_update.visit_count,
|
111 |
+
LoyaltyProgramModel.id != tier_id,
|
112 |
+
)
|
113 |
+
.first()
|
114 |
+
)
|
115 |
+
if existing_tier:
|
116 |
+
raise HTTPException(
|
117 |
+
status_code=400,
|
118 |
+
detail=f"Loyalty tier with visit count {tier_update.visit_count} already exists",
|
119 |
+
)
|
120 |
+
db_tier.visit_count = tier_update.visit_count
|
121 |
+
|
122 |
+
# Update other fields if provided
|
123 |
+
if tier_update.discount_percentage is not None:
|
124 |
+
db_tier.discount_percentage = tier_update.discount_percentage
|
125 |
+
if tier_update.is_active is not None:
|
126 |
+
db_tier.is_active = tier_update.is_active
|
127 |
+
|
128 |
+
db_tier.updated_at = datetime.now(timezone.utc)
|
129 |
+
db.commit()
|
130 |
+
db.refresh(db_tier)
|
131 |
+
return db_tier
|
132 |
+
|
133 |
+
|
134 |
+
# Delete loyalty tier
|
135 |
+
@router.delete("/{tier_id}")
|
136 |
+
def delete_loyalty_tier(tier_id: int, request: Request, db: Session = Depends(get_session_database)):
|
137 |
+
db_tier = (
|
138 |
+
db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.id == tier_id).first()
|
139 |
+
)
|
140 |
+
if not db_tier:
|
141 |
+
raise HTTPException(status_code=404, detail="Loyalty tier not found")
|
142 |
+
|
143 |
+
db.delete(db_tier)
|
144 |
+
db.commit()
|
145 |
+
return {"message": "Loyalty tier deleted successfully"}
|
146 |
+
|
147 |
+
|
148 |
+
# Get applicable discount for a visit count
|
149 |
+
@router.get("/discount/{visit_count}")
|
150 |
+
def get_discount_for_visit_count(visit_count: int, request: Request, db: Session = Depends(get_session_database)):
|
151 |
+
hotel_id = get_hotel_id_from_request(request)
|
152 |
+
|
153 |
+
# Find the tier that exactly matches the visit count for this hotel
|
154 |
+
applicable_tier = (
|
155 |
+
db.query(LoyaltyProgramModel)
|
156 |
+
.filter(
|
157 |
+
LoyaltyProgramModel.hotel_id == hotel_id,
|
158 |
+
LoyaltyProgramModel.visit_count == visit_count,
|
159 |
+
LoyaltyProgramModel.is_active == True,
|
160 |
+
)
|
161 |
+
.first()
|
162 |
+
)
|
163 |
+
|
164 |
+
if not applicable_tier:
|
165 |
+
return {"discount_percentage": 0, "message": "No applicable loyalty discount"}
|
166 |
+
|
167 |
+
return {
|
168 |
+
"discount_percentage": applicable_tier.discount_percentage,
|
169 |
+
"tier_id": applicable_tier.id,
|
170 |
+
"visit_count": applicable_tier.visit_count,
|
171 |
+
"message": f"Loyalty discount of {applicable_tier.discount_percentage}% applied for {visit_count} visits",
|
172 |
+
}
|
app/routers/selection_offer.py
ADDED
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import List
|
4 |
+
from datetime import datetime, timezone
|
5 |
+
|
6 |
+
from ..database import get_db, SelectionOffer as SelectionOfferModel, get_session_db, get_hotel_id_from_request
|
7 |
+
from ..models.selection_offer import (
|
8 |
+
SelectionOffer,
|
9 |
+
SelectionOfferCreate,
|
10 |
+
SelectionOfferUpdate,
|
11 |
+
)
|
12 |
+
from ..middleware import get_session_id
|
13 |
+
|
14 |
+
router = APIRouter(
|
15 |
+
prefix="/selection-offers",
|
16 |
+
tags=["selection-offers"],
|
17 |
+
responses={404: {"description": "Not found"}},
|
18 |
+
)
|
19 |
+
|
20 |
+
|
21 |
+
# Dependency to get session-aware database
|
22 |
+
def get_session_database(request: Request):
|
23 |
+
session_id = get_session_id(request)
|
24 |
+
return next(get_session_db(session_id))
|
25 |
+
|
26 |
+
|
27 |
+
# Get all selection offers
|
28 |
+
@router.get("/", response_model=List[SelectionOffer])
|
29 |
+
def get_all_selection_offers(request: Request, db: Session = Depends(get_session_database)):
|
30 |
+
hotel_id = get_hotel_id_from_request(request)
|
31 |
+
return db.query(SelectionOfferModel).filter(SelectionOfferModel.hotel_id == hotel_id).order_by(SelectionOfferModel.min_amount).all()
|
32 |
+
|
33 |
+
|
34 |
+
# Get active selection offers
|
35 |
+
@router.get("/active", response_model=List[SelectionOffer])
|
36 |
+
def get_active_selection_offers(request: Request, db: Session = Depends(get_session_database)):
|
37 |
+
hotel_id = get_hotel_id_from_request(request)
|
38 |
+
return (
|
39 |
+
db.query(SelectionOfferModel)
|
40 |
+
.filter(
|
41 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
42 |
+
SelectionOfferModel.is_active == True
|
43 |
+
)
|
44 |
+
.order_by(SelectionOfferModel.min_amount)
|
45 |
+
.all()
|
46 |
+
)
|
47 |
+
|
48 |
+
|
49 |
+
# Get selection offer by ID
|
50 |
+
@router.get("/{offer_id}", response_model=SelectionOffer)
|
51 |
+
def get_selection_offer(offer_id: int, request: Request, db: Session = Depends(get_session_database)):
|
52 |
+
hotel_id = get_hotel_id_from_request(request)
|
53 |
+
db_offer = (
|
54 |
+
db.query(SelectionOfferModel).filter(
|
55 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
56 |
+
SelectionOfferModel.id == offer_id
|
57 |
+
).first()
|
58 |
+
)
|
59 |
+
if not db_offer:
|
60 |
+
raise HTTPException(status_code=404, detail="Selection offer not found")
|
61 |
+
return db_offer
|
62 |
+
|
63 |
+
|
64 |
+
# Create new selection offer
|
65 |
+
@router.post("/", response_model=SelectionOffer)
|
66 |
+
def create_selection_offer(offer: SelectionOfferCreate, request: Request, db: Session = Depends(get_session_database)):
|
67 |
+
hotel_id = get_hotel_id_from_request(request)
|
68 |
+
|
69 |
+
# Check if an offer with this min_amount already exists for this hotel
|
70 |
+
existing_offer = (
|
71 |
+
db.query(SelectionOfferModel)
|
72 |
+
.filter(
|
73 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
74 |
+
SelectionOfferModel.min_amount == offer.min_amount
|
75 |
+
)
|
76 |
+
.first()
|
77 |
+
)
|
78 |
+
if existing_offer:
|
79 |
+
raise HTTPException(
|
80 |
+
status_code=400,
|
81 |
+
detail=f"Selection offer with minimum amount {offer.min_amount} already exists",
|
82 |
+
)
|
83 |
+
|
84 |
+
# Create new offer
|
85 |
+
db_offer = SelectionOfferModel(
|
86 |
+
hotel_id=hotel_id,
|
87 |
+
min_amount=offer.min_amount,
|
88 |
+
discount_amount=offer.discount_amount,
|
89 |
+
is_active=offer.is_active,
|
90 |
+
description=offer.description,
|
91 |
+
created_at=datetime.now(timezone.utc),
|
92 |
+
updated_at=datetime.now(timezone.utc),
|
93 |
+
)
|
94 |
+
db.add(db_offer)
|
95 |
+
db.commit()
|
96 |
+
db.refresh(db_offer)
|
97 |
+
return db_offer
|
98 |
+
|
99 |
+
|
100 |
+
# Update selection offer
|
101 |
+
@router.put("/{offer_id}", response_model=SelectionOffer)
|
102 |
+
def update_selection_offer(
|
103 |
+
offer_id: int, offer_update: SelectionOfferUpdate, request: Request, db: Session = Depends(get_session_database)
|
104 |
+
):
|
105 |
+
hotel_id = get_hotel_id_from_request(request)
|
106 |
+
|
107 |
+
db_offer = (
|
108 |
+
db.query(SelectionOfferModel).filter(
|
109 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
110 |
+
SelectionOfferModel.id == offer_id
|
111 |
+
).first()
|
112 |
+
)
|
113 |
+
if not db_offer:
|
114 |
+
raise HTTPException(status_code=404, detail="Selection offer not found")
|
115 |
+
|
116 |
+
# Check if updating min_amount and if it already exists for this hotel
|
117 |
+
if (
|
118 |
+
offer_update.min_amount is not None
|
119 |
+
and offer_update.min_amount != db_offer.min_amount
|
120 |
+
):
|
121 |
+
existing_offer = (
|
122 |
+
db.query(SelectionOfferModel)
|
123 |
+
.filter(
|
124 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
125 |
+
SelectionOfferModel.min_amount == offer_update.min_amount,
|
126 |
+
SelectionOfferModel.id != offer_id,
|
127 |
+
)
|
128 |
+
.first()
|
129 |
+
)
|
130 |
+
if existing_offer:
|
131 |
+
raise HTTPException(
|
132 |
+
status_code=400,
|
133 |
+
detail=f"Selection offer with minimum amount {offer_update.min_amount} already exists",
|
134 |
+
)
|
135 |
+
db_offer.min_amount = offer_update.min_amount
|
136 |
+
|
137 |
+
# Update other fields if provided
|
138 |
+
if offer_update.discount_amount is not None:
|
139 |
+
db_offer.discount_amount = offer_update.discount_amount
|
140 |
+
if offer_update.is_active is not None:
|
141 |
+
db_offer.is_active = offer_update.is_active
|
142 |
+
if offer_update.description is not None:
|
143 |
+
db_offer.description = offer_update.description
|
144 |
+
|
145 |
+
db_offer.updated_at = datetime.now(timezone.utc)
|
146 |
+
db.commit()
|
147 |
+
db.refresh(db_offer)
|
148 |
+
return db_offer
|
149 |
+
|
150 |
+
|
151 |
+
# Delete selection offer
|
152 |
+
@router.delete("/{offer_id}")
|
153 |
+
def delete_selection_offer(offer_id: int, request: Request, db: Session = Depends(get_session_database)):
|
154 |
+
hotel_id = get_hotel_id_from_request(request)
|
155 |
+
|
156 |
+
db_offer = (
|
157 |
+
db.query(SelectionOfferModel).filter(
|
158 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
159 |
+
SelectionOfferModel.id == offer_id
|
160 |
+
).first()
|
161 |
+
)
|
162 |
+
if not db_offer:
|
163 |
+
raise HTTPException(status_code=404, detail="Selection offer not found")
|
164 |
+
|
165 |
+
db.delete(db_offer)
|
166 |
+
db.commit()
|
167 |
+
return {"message": "Selection offer deleted successfully"}
|
168 |
+
|
169 |
+
|
170 |
+
# Get applicable discount for an order amount
|
171 |
+
@router.get("/discount/{order_amount}")
|
172 |
+
def get_discount_for_order_amount(order_amount: float, request: Request, db: Session = Depends(get_session_database)):
|
173 |
+
hotel_id = get_hotel_id_from_request(request)
|
174 |
+
|
175 |
+
# Find the highest tier that the order amount qualifies for this hotel
|
176 |
+
applicable_offer = (
|
177 |
+
db.query(SelectionOfferModel)
|
178 |
+
.filter(
|
179 |
+
SelectionOfferModel.hotel_id == hotel_id,
|
180 |
+
SelectionOfferModel.min_amount <= order_amount,
|
181 |
+
SelectionOfferModel.is_active == True,
|
182 |
+
)
|
183 |
+
.order_by(SelectionOfferModel.min_amount.desc())
|
184 |
+
.first()
|
185 |
+
)
|
186 |
+
|
187 |
+
if not applicable_offer:
|
188 |
+
return {
|
189 |
+
"discount_amount": 0,
|
190 |
+
"message": "No applicable selection offer discount",
|
191 |
+
}
|
192 |
+
|
193 |
+
return {
|
194 |
+
"discount_amount": applicable_offer.discount_amount,
|
195 |
+
"offer_id": applicable_offer.id,
|
196 |
+
"min_amount": applicable_offer.min_amount,
|
197 |
+
"message": f"Selection offer discount of ₹{applicable_offer.discount_amount} applied",
|
198 |
+
}
|
app/routers/settings.py
ADDED
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import Optional, List
|
4 |
+
import os
|
5 |
+
import shutil
|
6 |
+
import csv
|
7 |
+
from datetime import datetime, timezone
|
8 |
+
|
9 |
+
from ..database import (
|
10 |
+
get_db, Settings, Hotel, switch_database, get_current_database,
|
11 |
+
get_session_db, get_session_current_database, set_session_hotel_context,
|
12 |
+
get_session_hotel_id, authenticate_hotel_session
|
13 |
+
)
|
14 |
+
from ..models.settings import Settings as SettingsModel, SettingsUpdate
|
15 |
+
from ..models.database_config import DatabaseEntry, DatabaseList, DatabaseSelectRequest, DatabaseSelectResponse
|
16 |
+
from ..middleware import get_session_id
|
17 |
+
|
18 |
+
router = APIRouter(
|
19 |
+
prefix="/settings",
|
20 |
+
tags=["settings"],
|
21 |
+
responses={404: {"description": "Not found"}},
|
22 |
+
)
|
23 |
+
|
24 |
+
|
25 |
+
# Dependency to get session-aware database
|
26 |
+
def get_session_database(request: Request):
|
27 |
+
session_id = get_session_id(request)
|
28 |
+
return next(get_session_db(session_id))
|
29 |
+
|
30 |
+
|
31 |
+
# Get available hotels from hotels.csv
|
32 |
+
@router.get("/hotels", response_model=DatabaseList)
|
33 |
+
def get_hotels():
|
34 |
+
try:
|
35 |
+
hotels = []
|
36 |
+
with open("hotels.csv", "r") as file:
|
37 |
+
reader = csv.DictReader(file)
|
38 |
+
for row in reader:
|
39 |
+
hotels.append(DatabaseEntry(
|
40 |
+
database_name=row["hotel_name"], # Using hotel_name instead of hotel_database
|
41 |
+
password=row["password"]
|
42 |
+
))
|
43 |
+
|
44 |
+
# Return only hotel names, not passwords
|
45 |
+
return {"databases": [{"database_name": hotel.database_name, "password": "********"} for hotel in hotels]}
|
46 |
+
except Exception as e:
|
47 |
+
raise HTTPException(status_code=500, detail=f"Error reading hotel configuration: {str(e)}")
|
48 |
+
|
49 |
+
|
50 |
+
# Legacy endpoint for backward compatibility
|
51 |
+
@router.get("/databases", response_model=DatabaseList)
|
52 |
+
def get_databases():
|
53 |
+
return get_hotels()
|
54 |
+
|
55 |
+
|
56 |
+
# Get current hotel info
|
57 |
+
@router.get("/current-hotel")
|
58 |
+
def get_current_hotel(request: Request):
|
59 |
+
session_id = get_session_id(request)
|
60 |
+
hotel_id = get_session_hotel_id(session_id)
|
61 |
+
if hotel_id:
|
62 |
+
# Get hotel name from database
|
63 |
+
db = next(get_session_database(request))
|
64 |
+
hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first()
|
65 |
+
if hotel:
|
66 |
+
return {"hotel_name": hotel.hotel_name, "hotel_id": hotel.id}
|
67 |
+
return {"hotel_name": None, "hotel_id": None}
|
68 |
+
|
69 |
+
|
70 |
+
# Legacy endpoint for backward compatibility
|
71 |
+
@router.get("/current-database")
|
72 |
+
def get_current_db(request: Request):
|
73 |
+
session_id = get_session_id(request)
|
74 |
+
return {"database_name": get_session_current_database(session_id)}
|
75 |
+
|
76 |
+
|
77 |
+
# Switch hotel
|
78 |
+
@router.post("/switch-hotel", response_model=DatabaseSelectResponse)
|
79 |
+
def select_hotel(request_data: DatabaseSelectRequest, request: Request):
|
80 |
+
try:
|
81 |
+
session_id = get_session_id(request)
|
82 |
+
|
83 |
+
# Authenticate hotel using hotel_name and password
|
84 |
+
hotel_id = authenticate_hotel_session(request_data.database_name, request_data.password)
|
85 |
+
|
86 |
+
if hotel_id:
|
87 |
+
# Set hotel context for this session
|
88 |
+
success = set_session_hotel_context(session_id, hotel_id)
|
89 |
+
if success:
|
90 |
+
return {
|
91 |
+
"success": True,
|
92 |
+
"message": f"Successfully switched to hotel: {request_data.database_name}"
|
93 |
+
}
|
94 |
+
else:
|
95 |
+
raise HTTPException(status_code=500, detail="Failed to set hotel context")
|
96 |
+
else:
|
97 |
+
raise HTTPException(status_code=401, detail="Invalid hotel credentials")
|
98 |
+
|
99 |
+
except HTTPException:
|
100 |
+
raise
|
101 |
+
except Exception as e:
|
102 |
+
raise HTTPException(status_code=500, detail=f"Error switching hotel: {str(e)}")
|
103 |
+
|
104 |
+
|
105 |
+
# Legacy endpoint for backward compatibility
|
106 |
+
@router.post("/switch-database", response_model=DatabaseSelectResponse)
|
107 |
+
def select_database(request_data: DatabaseSelectRequest, request: Request):
|
108 |
+
return select_hotel(request_data, request)
|
109 |
+
|
110 |
+
|
111 |
+
# Get hotel settings
|
112 |
+
@router.get("/", response_model=SettingsModel)
|
113 |
+
def get_settings(request: Request, db: Session = Depends(get_session_database)):
|
114 |
+
session_id = get_session_id(request)
|
115 |
+
hotel_id = get_session_hotel_id(session_id)
|
116 |
+
|
117 |
+
if not hotel_id:
|
118 |
+
raise HTTPException(status_code=400, detail="No hotel context set")
|
119 |
+
|
120 |
+
# Get settings for the current hotel
|
121 |
+
settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first()
|
122 |
+
|
123 |
+
if not settings:
|
124 |
+
# Get hotel info for default settings
|
125 |
+
hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first()
|
126 |
+
if not hotel:
|
127 |
+
raise HTTPException(status_code=404, detail="Hotel not found")
|
128 |
+
|
129 |
+
# Create default settings for this hotel
|
130 |
+
settings = Settings(
|
131 |
+
hotel_id=hotel_id,
|
132 |
+
hotel_name=hotel.hotel_name,
|
133 |
+
address="123 Main Street, City",
|
134 |
+
contact_number="+1 123-456-7890",
|
135 |
+
email="[email protected]",
|
136 |
+
)
|
137 |
+
db.add(settings)
|
138 |
+
db.commit()
|
139 |
+
db.refresh(settings)
|
140 |
+
|
141 |
+
return settings
|
142 |
+
|
143 |
+
|
144 |
+
# Update hotel settings
|
145 |
+
@router.put("/", response_model=SettingsModel)
|
146 |
+
async def update_settings(
|
147 |
+
request: Request,
|
148 |
+
hotel_name: str = Form(...),
|
149 |
+
address: Optional[str] = Form(None),
|
150 |
+
contact_number: Optional[str] = Form(None),
|
151 |
+
email: Optional[str] = Form(None),
|
152 |
+
tax_id: Optional[str] = Form(None),
|
153 |
+
logo: Optional[UploadFile] = File(None),
|
154 |
+
db: Session = Depends(get_session_database)
|
155 |
+
):
|
156 |
+
session_id = get_session_id(request)
|
157 |
+
hotel_id = get_session_hotel_id(session_id)
|
158 |
+
|
159 |
+
if not hotel_id:
|
160 |
+
raise HTTPException(status_code=400, detail="No hotel context set")
|
161 |
+
|
162 |
+
# Get existing settings for this hotel or create new
|
163 |
+
settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first()
|
164 |
+
|
165 |
+
if not settings:
|
166 |
+
settings = Settings(
|
167 |
+
hotel_id=hotel_id,
|
168 |
+
hotel_name=hotel_name,
|
169 |
+
address=address,
|
170 |
+
contact_number=contact_number,
|
171 |
+
email=email,
|
172 |
+
tax_id=tax_id,
|
173 |
+
)
|
174 |
+
db.add(settings)
|
175 |
+
else:
|
176 |
+
# Update fields
|
177 |
+
settings.hotel_name = hotel_name
|
178 |
+
settings.address = address
|
179 |
+
settings.contact_number = contact_number
|
180 |
+
settings.email = email
|
181 |
+
settings.tax_id = tax_id
|
182 |
+
|
183 |
+
# Handle logo upload if provided
|
184 |
+
if logo:
|
185 |
+
# Get hotel info for organizing logos
|
186 |
+
hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first()
|
187 |
+
hotel_name_for_path = hotel.hotel_name if hotel else f"hotel_{hotel_id}"
|
188 |
+
|
189 |
+
# Create directory structure: app/static/images/logo/{hotel_name}
|
190 |
+
hotel_logo_dir = f"app/static/images/logo/{hotel_name_for_path}"
|
191 |
+
os.makedirs(hotel_logo_dir, exist_ok=True)
|
192 |
+
|
193 |
+
# Save logo with hotel-specific path
|
194 |
+
logo_path = f"{hotel_logo_dir}/hotel_logo_{logo.filename}"
|
195 |
+
with open(logo_path, "wb") as buffer:
|
196 |
+
shutil.copyfileobj(logo.file, buffer)
|
197 |
+
|
198 |
+
# Update settings with logo path (URL path for serving)
|
199 |
+
settings.logo_path = f"/static/images/logo/{hotel_name_for_path}/hotel_logo_{logo.filename}"
|
200 |
+
|
201 |
+
# Update timestamp
|
202 |
+
settings.updated_at = datetime.now(timezone.utc)
|
203 |
+
|
204 |
+
db.commit()
|
205 |
+
db.refresh(settings)
|
206 |
+
|
207 |
+
return settings
|
app/routers/table.py
ADDED
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from typing import List
|
4 |
+
from datetime import datetime, timezone
|
5 |
+
|
6 |
+
from ..database import get_db, Table as TableModel, Order, get_session_db
|
7 |
+
from ..models.table import Table, TableCreate, TableUpdate, TableStatus
|
8 |
+
from ..middleware import get_session_id
|
9 |
+
|
10 |
+
router = APIRouter(
|
11 |
+
prefix="/tables",
|
12 |
+
tags=["tables"],
|
13 |
+
responses={404: {"description": "Not found"}},
|
14 |
+
)
|
15 |
+
|
16 |
+
|
17 |
+
# Dependency to get session-aware database
|
18 |
+
def get_session_database(request: Request):
|
19 |
+
session_id = get_session_id(request)
|
20 |
+
return next(get_session_db(session_id))
|
21 |
+
|
22 |
+
|
23 |
+
# Get all tables
|
24 |
+
@router.get("/", response_model=List[Table])
|
25 |
+
def get_all_tables(request: Request, db: Session = Depends(get_session_database)):
|
26 |
+
return db.query(TableModel).order_by(TableModel.table_number).all()
|
27 |
+
|
28 |
+
|
29 |
+
# Get table by ID
|
30 |
+
@router.get("/{table_id}", response_model=Table)
|
31 |
+
def get_table(table_id: int, request: Request, db: Session = Depends(get_session_database)):
|
32 |
+
db_table = db.query(TableModel).filter(TableModel.id == table_id).first()
|
33 |
+
if not db_table:
|
34 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
35 |
+
return db_table
|
36 |
+
|
37 |
+
|
38 |
+
# Get table by table number
|
39 |
+
@router.get("/number/{table_number}", response_model=Table)
|
40 |
+
def get_table_by_number(table_number: int, request: Request, db: Session = Depends(get_session_database)):
|
41 |
+
db_table = (
|
42 |
+
db.query(TableModel).filter(TableModel.table_number == table_number).first()
|
43 |
+
)
|
44 |
+
if not db_table:
|
45 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
46 |
+
return db_table
|
47 |
+
|
48 |
+
|
49 |
+
# Create new table
|
50 |
+
@router.post("/", response_model=Table)
|
51 |
+
def create_table(table: TableCreate, request: Request, db: Session = Depends(get_session_database)):
|
52 |
+
# Check if a table with this number already exists
|
53 |
+
existing_table = (
|
54 |
+
db.query(TableModel)
|
55 |
+
.filter(TableModel.table_number == table.table_number)
|
56 |
+
.first()
|
57 |
+
)
|
58 |
+
if existing_table:
|
59 |
+
raise HTTPException(
|
60 |
+
status_code=400,
|
61 |
+
detail=f"Table with number {table.table_number} already exists",
|
62 |
+
)
|
63 |
+
|
64 |
+
# Create new table
|
65 |
+
db_table = TableModel(
|
66 |
+
table_number=table.table_number,
|
67 |
+
is_occupied=table.is_occupied,
|
68 |
+
current_order_id=table.current_order_id,
|
69 |
+
last_occupied_at=table.last_occupied_at,
|
70 |
+
created_at=datetime.now(timezone.utc),
|
71 |
+
updated_at=datetime.now(timezone.utc),
|
72 |
+
)
|
73 |
+
db.add(db_table)
|
74 |
+
db.commit()
|
75 |
+
db.refresh(db_table)
|
76 |
+
return db_table
|
77 |
+
|
78 |
+
|
79 |
+
# Update table
|
80 |
+
@router.put("/{table_id}", response_model=Table)
|
81 |
+
def update_table(
|
82 |
+
table_id: int, table_update: TableUpdate, request: Request, db: Session = Depends(get_session_database)
|
83 |
+
):
|
84 |
+
db_table = db.query(TableModel).filter(TableModel.id == table_id).first()
|
85 |
+
if not db_table:
|
86 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
87 |
+
|
88 |
+
# Update fields if provided
|
89 |
+
if table_update.is_occupied is not None:
|
90 |
+
db_table.is_occupied = table_update.is_occupied
|
91 |
+
if table_update.current_order_id is not None:
|
92 |
+
db_table.current_order_id = table_update.current_order_id
|
93 |
+
|
94 |
+
db_table.updated_at = datetime.now(timezone.utc)
|
95 |
+
db.commit()
|
96 |
+
db.refresh(db_table)
|
97 |
+
return db_table
|
98 |
+
|
99 |
+
|
100 |
+
# Delete table
|
101 |
+
@router.delete("/{table_id}")
|
102 |
+
def delete_table(table_id: int, request: Request, db: Session = Depends(get_session_database)):
|
103 |
+
db_table = db.query(TableModel).filter(TableModel.id == table_id).first()
|
104 |
+
if not db_table:
|
105 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
106 |
+
|
107 |
+
# Check if table is currently occupied
|
108 |
+
if db_table.is_occupied:
|
109 |
+
raise HTTPException(
|
110 |
+
status_code=400, detail="Cannot delete a table that is currently occupied"
|
111 |
+
)
|
112 |
+
|
113 |
+
db.delete(db_table)
|
114 |
+
db.commit()
|
115 |
+
return {"message": "Table deleted successfully"}
|
116 |
+
|
117 |
+
|
118 |
+
# Get table status (total, occupied, free)
|
119 |
+
@router.get("/status/summary", response_model=TableStatus)
|
120 |
+
def get_table_status(request: Request, db: Session = Depends(get_session_database)):
|
121 |
+
total_tables = db.query(TableModel).count()
|
122 |
+
occupied_tables = (
|
123 |
+
db.query(TableModel).filter(TableModel.is_occupied == True).count()
|
124 |
+
)
|
125 |
+
free_tables = total_tables - occupied_tables
|
126 |
+
|
127 |
+
return {
|
128 |
+
"total_tables": total_tables,
|
129 |
+
"occupied_tables": occupied_tables,
|
130 |
+
"free_tables": free_tables,
|
131 |
+
}
|
132 |
+
|
133 |
+
|
134 |
+
# Set table as occupied
|
135 |
+
@router.put("/{table_id}/occupy", response_model=Table)
|
136 |
+
def set_table_occupied(
|
137 |
+
table_id: int, order_id: int = None, request: Request = None, db: Session = Depends(get_session_database)
|
138 |
+
):
|
139 |
+
db_table = db.query(TableModel).filter(TableModel.id == table_id).first()
|
140 |
+
if not db_table:
|
141 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
142 |
+
|
143 |
+
# Check if table is already occupied
|
144 |
+
if db_table.is_occupied:
|
145 |
+
raise HTTPException(status_code=400, detail="Table is already occupied")
|
146 |
+
|
147 |
+
# Update table status
|
148 |
+
db_table.is_occupied = True
|
149 |
+
|
150 |
+
# Link to order if provided
|
151 |
+
if order_id:
|
152 |
+
# Verify order exists
|
153 |
+
order = db.query(Order).filter(Order.id == order_id).first()
|
154 |
+
if not order:
|
155 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
156 |
+
db_table.current_order_id = order_id
|
157 |
+
|
158 |
+
db_table.updated_at = datetime.now(timezone.utc)
|
159 |
+
db.commit()
|
160 |
+
db.refresh(db_table)
|
161 |
+
return db_table
|
162 |
+
|
163 |
+
|
164 |
+
# Set table as free
|
165 |
+
@router.put("/{table_id}/free", response_model=Table)
|
166 |
+
def set_table_free(table_id: int, request: Request, db: Session = Depends(get_session_database)):
|
167 |
+
db_table = db.query(TableModel).filter(TableModel.id == table_id).first()
|
168 |
+
if not db_table:
|
169 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
170 |
+
|
171 |
+
# Check if table is already free
|
172 |
+
if not db_table.is_occupied:
|
173 |
+
raise HTTPException(status_code=400, detail="Table is already free")
|
174 |
+
|
175 |
+
# Update table status
|
176 |
+
db_table.is_occupied = False
|
177 |
+
db_table.current_order_id = None
|
178 |
+
db_table.updated_at = datetime.now(timezone.utc)
|
179 |
+
db.commit()
|
180 |
+
db.refresh(db_table)
|
181 |
+
return db_table
|
182 |
+
|
183 |
+
|
184 |
+
# Set table as occupied by table number
|
185 |
+
@router.put("/number/{table_number}/occupy", response_model=Table)
|
186 |
+
def set_table_occupied_by_number(table_number: int, request: Request, db: Session = Depends(get_session_database)):
|
187 |
+
db_table = (
|
188 |
+
db.query(TableModel).filter(TableModel.table_number == table_number).first()
|
189 |
+
)
|
190 |
+
if not db_table:
|
191 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
192 |
+
|
193 |
+
# Update table status (even if already occupied, just update the timestamp)
|
194 |
+
db_table.is_occupied = True
|
195 |
+
db_table.last_occupied_at = datetime.now(timezone.utc)
|
196 |
+
db_table.updated_at = datetime.now(timezone.utc)
|
197 |
+
db.commit()
|
198 |
+
db.refresh(db_table)
|
199 |
+
return db_table
|
200 |
+
|
201 |
+
|
202 |
+
# Set table as free by table number
|
203 |
+
@router.put("/number/{table_number}/free", response_model=Table)
|
204 |
+
def set_table_free_by_number(table_number: int, request: Request, db: Session = Depends(get_session_database)):
|
205 |
+
db_table = (
|
206 |
+
db.query(TableModel).filter(TableModel.table_number == table_number).first()
|
207 |
+
)
|
208 |
+
if not db_table:
|
209 |
+
raise HTTPException(status_code=404, detail="Table not found")
|
210 |
+
|
211 |
+
# Update table status to free (don't check if already free, just update)
|
212 |
+
db_table.is_occupied = False
|
213 |
+
db_table.current_order_id = None
|
214 |
+
db_table.updated_at = datetime.now(timezone.utc)
|
215 |
+
db.commit()
|
216 |
+
db.refresh(db_table)
|
217 |
+
return db_table
|
218 |
+
|
219 |
+
|
220 |
+
# Create multiple tables at once
|
221 |
+
@router.post("/batch", response_model=List[Table])
|
222 |
+
def create_tables_batch(num_tables: int, request: Request, db: Session = Depends(get_session_database)):
|
223 |
+
if num_tables <= 0:
|
224 |
+
raise HTTPException(
|
225 |
+
status_code=400, detail="Number of tables must be greater than 0"
|
226 |
+
)
|
227 |
+
|
228 |
+
# Get the highest existing table number
|
229 |
+
highest_table = (
|
230 |
+
db.query(TableModel).order_by(TableModel.table_number.desc()).first()
|
231 |
+
)
|
232 |
+
start_number = 1
|
233 |
+
if highest_table:
|
234 |
+
start_number = highest_table.table_number + 1
|
235 |
+
|
236 |
+
# Create tables
|
237 |
+
new_tables = []
|
238 |
+
for i in range(start_number, start_number + num_tables):
|
239 |
+
db_table = TableModel(
|
240 |
+
table_number=i,
|
241 |
+
is_occupied=False,
|
242 |
+
created_at=datetime.now(timezone.utc),
|
243 |
+
updated_at=datetime.now(timezone.utc),
|
244 |
+
)
|
245 |
+
db.add(db_table)
|
246 |
+
new_tables.append(db_table)
|
247 |
+
|
248 |
+
db.commit()
|
249 |
+
|
250 |
+
# Refresh all tables
|
251 |
+
for table in new_tables:
|
252 |
+
db.refresh(table)
|
253 |
+
|
254 |
+
return new_tables
|
app/services/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# Services package
|
app/services/firebase_auth.py
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import firebase_admin
|
2 |
+
from firebase_admin import credentials, auth
|
3 |
+
import os
|
4 |
+
import json
|
5 |
+
from fastapi import HTTPException, status
|
6 |
+
|
7 |
+
# Initialize Firebase Admin SDK
|
8 |
+
cred_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
9 |
+
"app", "tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json")
|
10 |
+
|
11 |
+
# Global variable to track initialization
|
12 |
+
firebase_initialized = False
|
13 |
+
|
14 |
+
try:
|
15 |
+
# Check if Firebase is already initialized
|
16 |
+
try:
|
17 |
+
firebase_app = firebase_admin.get_app()
|
18 |
+
firebase_initialized = True
|
19 |
+
print("Firebase already initialized")
|
20 |
+
except ValueError:
|
21 |
+
# Initialize Firebase if not already initialized
|
22 |
+
cred = credentials.Certificate(cred_path)
|
23 |
+
firebase_app = firebase_admin.initialize_app(cred)
|
24 |
+
firebase_initialized = True
|
25 |
+
print("Firebase initialized successfully")
|
26 |
+
except Exception as e:
|
27 |
+
print(f"Firebase initialization error: {e}")
|
28 |
+
# Continue without crashing, but authentication will fail
|
29 |
+
|
30 |
+
# Firebase Authentication functions
|
31 |
+
def verify_phone_number(phone_number):
|
32 |
+
"""
|
33 |
+
Verify a phone number and send OTP
|
34 |
+
Returns a session info token that will be used to verify the OTP
|
35 |
+
"""
|
36 |
+
try:
|
37 |
+
# Check if Firebase is initialized
|
38 |
+
if not firebase_initialized:
|
39 |
+
print("Firebase is not initialized, using mock verification")
|
40 |
+
|
41 |
+
# Validate phone number format (should start with +91)
|
42 |
+
if not phone_number.startswith("+91"):
|
43 |
+
raise HTTPException(
|
44 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
45 |
+
detail="Phone number must start with +91"
|
46 |
+
)
|
47 |
+
|
48 |
+
# In a real implementation with Firebase Admin SDK, we would use:
|
49 |
+
# session_info = auth.create_session_cookie(...)
|
50 |
+
# But for this implementation, we'll let the client-side Firebase handle the actual SMS sending
|
51 |
+
|
52 |
+
print(f"Phone verification requested for: {phone_number}")
|
53 |
+
return {"sessionInfo": "firebase-verification-token", "success": True}
|
54 |
+
|
55 |
+
except HTTPException as e:
|
56 |
+
# Re-raise HTTP exceptions
|
57 |
+
raise e
|
58 |
+
except Exception as e:
|
59 |
+
print(f"Error in verify_phone_number: {str(e)}")
|
60 |
+
raise HTTPException(
|
61 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
62 |
+
detail=f"Failed to send verification code: {str(e)}"
|
63 |
+
)
|
64 |
+
|
65 |
+
def verify_otp(phone_number, otp, session_info=None):
|
66 |
+
"""
|
67 |
+
Verify the OTP sent to the phone number
|
68 |
+
Returns a Firebase ID token if verification is successful
|
69 |
+
|
70 |
+
Note: In this implementation, the actual OTP verification is done on the client side
|
71 |
+
using Firebase Authentication. This function is just for validating the format and
|
72 |
+
returning a success response.
|
73 |
+
"""
|
74 |
+
try:
|
75 |
+
# Check if Firebase is initialized
|
76 |
+
if not firebase_initialized:
|
77 |
+
print("Firebase is not initialized, using mock verification")
|
78 |
+
|
79 |
+
# Validate OTP format
|
80 |
+
if not otp.isdigit() or len(otp) != 6:
|
81 |
+
raise HTTPException(
|
82 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
83 |
+
detail="Invalid OTP format. Must be 6 digits."
|
84 |
+
)
|
85 |
+
|
86 |
+
# In a real implementation with Firebase Admin SDK, we would verify the OTP
|
87 |
+
# But for this implementation, we trust that the client-side Firebase has already verified it
|
88 |
+
|
89 |
+
print(f"OTP verification successful for: {phone_number}")
|
90 |
+
return {"idToken": "firebase-id-token", "phone_number": phone_number, "success": True}
|
91 |
+
|
92 |
+
except HTTPException as e:
|
93 |
+
# Re-raise HTTP exceptions
|
94 |
+
raise e
|
95 |
+
except Exception as e:
|
96 |
+
print(f"Error in verify_otp: {str(e)}")
|
97 |
+
raise HTTPException(
|
98 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
99 |
+
detail=f"Failed to verify OTP: {str(e)}"
|
100 |
+
)
|
app/services/optimized_queries.py
ADDED
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Optimized database queries for better performance
|
3 |
+
"""
|
4 |
+
from sqlalchemy.orm import Session, joinedload, selectinload
|
5 |
+
from sqlalchemy import and_, or_, func, text
|
6 |
+
from typing import List, Optional, Dict, Any
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
import logging
|
9 |
+
|
10 |
+
from ..database import Order, OrderItem, Dish, Person, Table
|
11 |
+
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
class OptimizedQueryService:
|
15 |
+
"""Service for optimized database queries with caching and performance improvements"""
|
16 |
+
|
17 |
+
def __init__(self):
|
18 |
+
self.query_cache = {}
|
19 |
+
self.cache_ttl = {
|
20 |
+
'menu': 300, # 5 minutes
|
21 |
+
'categories': 900, # 15 minutes
|
22 |
+
'specials': 300, # 5 minutes
|
23 |
+
'offers': 300, # 5 minutes
|
24 |
+
}
|
25 |
+
|
26 |
+
def get_menu_optimized(self, db: Session, category: Optional[str] = None) -> List[Dict]:
|
27 |
+
"""Optimized menu query with eager loading and caching"""
|
28 |
+
try:
|
29 |
+
# Build optimized query
|
30 |
+
query = db.query(Dish).filter(
|
31 |
+
Dish.is_visible == True
|
32 |
+
)
|
33 |
+
|
34 |
+
if category and category != 'All':
|
35 |
+
query = query.filter(Dish.category == category)
|
36 |
+
|
37 |
+
# Order by category and name for consistent results
|
38 |
+
query = query.order_by(Dish.category, Dish.name)
|
39 |
+
|
40 |
+
# Execute query
|
41 |
+
dishes = query.all()
|
42 |
+
|
43 |
+
# Convert to dict for JSON serialization
|
44 |
+
result = []
|
45 |
+
for dish in dishes:
|
46 |
+
dish_dict = {
|
47 |
+
'id': dish.id,
|
48 |
+
'name': dish.name,
|
49 |
+
'description': dish.description,
|
50 |
+
'price': float(dish.price),
|
51 |
+
'category': dish.category,
|
52 |
+
'image_path': dish.image_path,
|
53 |
+
'is_offer': dish.is_offer,
|
54 |
+
'discount': float(dish.discount) if dish.discount else 0,
|
55 |
+
'is_visible': dish.is_visible,
|
56 |
+
'created_at': dish.created_at.isoformat() if dish.created_at else None
|
57 |
+
}
|
58 |
+
result.append(dish_dict)
|
59 |
+
|
60 |
+
return result
|
61 |
+
|
62 |
+
except Exception as e:
|
63 |
+
logger.error(f"Error in get_menu_optimized: {str(e)}")
|
64 |
+
raise
|
65 |
+
|
66 |
+
def get_orders_optimized(self, db: Session, person_id: Optional[int] = None,
|
67 |
+
table_number: Optional[int] = None,
|
68 |
+
status: Optional[str] = None) -> List[Dict]:
|
69 |
+
"""Optimized order query with eager loading of related data"""
|
70 |
+
try:
|
71 |
+
# Build base query with eager loading
|
72 |
+
query = db.query(Order).options(
|
73 |
+
selectinload(Order.items).selectinload(OrderItem.dish),
|
74 |
+
joinedload(Order.person)
|
75 |
+
)
|
76 |
+
|
77 |
+
# Apply filters
|
78 |
+
filters = []
|
79 |
+
if person_id:
|
80 |
+
filters.append(Order.person_id == person_id)
|
81 |
+
if table_number:
|
82 |
+
filters.append(Order.table_number == table_number)
|
83 |
+
if status:
|
84 |
+
filters.append(Order.status == status)
|
85 |
+
|
86 |
+
if filters:
|
87 |
+
query = query.filter(and_(*filters))
|
88 |
+
|
89 |
+
# Order by creation time (newest first)
|
90 |
+
query = query.order_by(Order.created_at.desc())
|
91 |
+
|
92 |
+
# Execute query
|
93 |
+
orders = query.all()
|
94 |
+
|
95 |
+
# Convert to dict with optimized serialization
|
96 |
+
result = []
|
97 |
+
for order in orders:
|
98 |
+
order_dict = {
|
99 |
+
'id': order.id,
|
100 |
+
'table_number': order.table_number,
|
101 |
+
'unique_id': order.unique_id,
|
102 |
+
'person_id': order.person_id,
|
103 |
+
'status': order.status,
|
104 |
+
'created_at': order.created_at.isoformat() if order.created_at else None,
|
105 |
+
'updated_at': order.updated_at.isoformat() if order.updated_at else None,
|
106 |
+
'items': []
|
107 |
+
}
|
108 |
+
|
109 |
+
# Add order items
|
110 |
+
for item in order.items:
|
111 |
+
item_dict = {
|
112 |
+
'id': item.id,
|
113 |
+
'dish_id': item.dish_id,
|
114 |
+
'dish_name': item.dish.name if item.dish else 'Unknown',
|
115 |
+
'quantity': item.quantity,
|
116 |
+
'price': float(item.price),
|
117 |
+
'remarks': item.remarks,
|
118 |
+
'position': item.position
|
119 |
+
}
|
120 |
+
order_dict['items'].append(item_dict)
|
121 |
+
|
122 |
+
result.append(order_dict)
|
123 |
+
|
124 |
+
return result
|
125 |
+
|
126 |
+
except Exception as e:
|
127 |
+
logger.error(f"Error in get_orders_optimized: {str(e)}")
|
128 |
+
raise
|
129 |
+
|
130 |
+
def get_chef_orders_optimized(self, db: Session, status: str) -> List[Dict]:
|
131 |
+
"""Optimized chef order query with minimal data transfer"""
|
132 |
+
try:
|
133 |
+
# Use raw SQL for better performance on chef queries
|
134 |
+
sql = text("""
|
135 |
+
SELECT
|
136 |
+
o.id,
|
137 |
+
o.table_number,
|
138 |
+
o.status,
|
139 |
+
o.created_at,
|
140 |
+
o.updated_at,
|
141 |
+
COUNT(oi.id) as item_count,
|
142 |
+
GROUP_CONCAT(
|
143 |
+
CONCAT(d.name, ' (', oi.quantity, ')')
|
144 |
+
SEPARATOR ', '
|
145 |
+
) as items_summary
|
146 |
+
FROM orders o
|
147 |
+
LEFT JOIN order_items oi ON o.id = oi.order_id
|
148 |
+
LEFT JOIN dishes d ON oi.dish_id = d.id
|
149 |
+
WHERE o.status = :status
|
150 |
+
GROUP BY o.id, o.table_number, o.status, o.created_at, o.updated_at
|
151 |
+
ORDER BY o.created_at ASC
|
152 |
+
""")
|
153 |
+
|
154 |
+
result = db.execute(sql, {'status': status}).fetchall()
|
155 |
+
|
156 |
+
# Convert to dict
|
157 |
+
orders = []
|
158 |
+
for row in result:
|
159 |
+
order_dict = {
|
160 |
+
'id': row.id,
|
161 |
+
'table_number': row.table_number,
|
162 |
+
'status': row.status,
|
163 |
+
'created_at': row.created_at.isoformat() if row.created_at else None,
|
164 |
+
'updated_at': row.updated_at.isoformat() if row.updated_at else None,
|
165 |
+
'item_count': row.item_count,
|
166 |
+
'items_summary': row.items_summary or ''
|
167 |
+
}
|
168 |
+
orders.append(order_dict)
|
169 |
+
|
170 |
+
return orders
|
171 |
+
|
172 |
+
except Exception as e:
|
173 |
+
logger.error(f"Error in get_chef_orders_optimized: {str(e)}")
|
174 |
+
# Fallback to regular query
|
175 |
+
return self._get_chef_orders_fallback(db, status)
|
176 |
+
|
177 |
+
def _get_chef_orders_fallback(self, db: Session, status: str) -> List[Dict]:
|
178 |
+
"""Fallback method for chef orders if raw SQL fails"""
|
179 |
+
try:
|
180 |
+
orders = db.query(Order).options(
|
181 |
+
selectinload(Order.items).selectinload(OrderItem.dish)
|
182 |
+
).filter(Order.status == status).order_by(Order.created_at.asc()).all()
|
183 |
+
|
184 |
+
result = []
|
185 |
+
for order in orders:
|
186 |
+
items_summary = ', '.join([
|
187 |
+
f"{item.dish.name if item.dish else 'Unknown'} ({item.quantity})"
|
188 |
+
for item in order.items
|
189 |
+
])
|
190 |
+
|
191 |
+
order_dict = {
|
192 |
+
'id': order.id,
|
193 |
+
'table_number': order.table_number,
|
194 |
+
'status': order.status,
|
195 |
+
'created_at': order.created_at.isoformat() if order.created_at else None,
|
196 |
+
'updated_at': order.updated_at.isoformat() if order.updated_at else None,
|
197 |
+
'item_count': len(order.items),
|
198 |
+
'items_summary': items_summary
|
199 |
+
}
|
200 |
+
result.append(order_dict)
|
201 |
+
|
202 |
+
return result
|
203 |
+
|
204 |
+
except Exception as e:
|
205 |
+
logger.error(f"Error in chef orders fallback: {str(e)}")
|
206 |
+
raise
|
207 |
+
|
208 |
+
def get_table_status_optimized(self, db: Session) -> List[Dict]:
|
209 |
+
"""Optimized table status query"""
|
210 |
+
try:
|
211 |
+
# Use raw SQL for better performance
|
212 |
+
sql = text("""
|
213 |
+
SELECT
|
214 |
+
t.table_number,
|
215 |
+
t.is_occupied,
|
216 |
+
t.current_order_id,
|
217 |
+
t.updated_at,
|
218 |
+
o.status as order_status,
|
219 |
+
COUNT(oi.id) as item_count
|
220 |
+
FROM tables t
|
221 |
+
LEFT JOIN orders o ON t.current_order_id = o.id
|
222 |
+
LEFT JOIN order_items oi ON o.id = oi.order_id
|
223 |
+
GROUP BY t.table_number, t.is_occupied, t.current_order_id, t.updated_at, o.status
|
224 |
+
ORDER BY t.table_number
|
225 |
+
""")
|
226 |
+
|
227 |
+
result = db.execute(sql).fetchall()
|
228 |
+
|
229 |
+
tables = []
|
230 |
+
for row in result:
|
231 |
+
table_dict = {
|
232 |
+
'table_number': row.table_number,
|
233 |
+
'is_occupied': bool(row.is_occupied),
|
234 |
+
'current_order_id': row.current_order_id,
|
235 |
+
'updated_at': row.updated_at.isoformat() if row.updated_at else None,
|
236 |
+
'order_status': row.order_status,
|
237 |
+
'item_count': row.item_count or 0
|
238 |
+
}
|
239 |
+
tables.append(table_dict)
|
240 |
+
|
241 |
+
return tables
|
242 |
+
|
243 |
+
except Exception as e:
|
244 |
+
logger.error(f"Error in get_table_status_optimized: {str(e)}")
|
245 |
+
raise
|
246 |
+
|
247 |
+
def get_analytics_data_optimized(self, db: Session, start_date: datetime,
|
248 |
+
end_date: datetime) -> Dict[str, Any]:
|
249 |
+
"""Optimized analytics query with aggregations"""
|
250 |
+
try:
|
251 |
+
# Use raw SQL for complex aggregations
|
252 |
+
sql = text("""
|
253 |
+
SELECT
|
254 |
+
DATE(o.created_at) as order_date,
|
255 |
+
COUNT(DISTINCT o.id) as total_orders,
|
256 |
+
COUNT(DISTINCT o.table_number) as unique_tables,
|
257 |
+
SUM(oi.quantity * oi.price) as total_revenue,
|
258 |
+
AVG(oi.quantity * oi.price) as avg_order_value,
|
259 |
+
d.category,
|
260 |
+
COUNT(oi.id) as items_sold
|
261 |
+
FROM orders o
|
262 |
+
JOIN order_items oi ON o.id = oi.order_id
|
263 |
+
JOIN dishes d ON oi.dish_id = d.id
|
264 |
+
WHERE o.created_at BETWEEN :start_date AND :end_date
|
265 |
+
AND o.status = 'paid'
|
266 |
+
GROUP BY DATE(o.created_at), d.category
|
267 |
+
ORDER BY order_date DESC, d.category
|
268 |
+
""")
|
269 |
+
|
270 |
+
result = db.execute(sql, {
|
271 |
+
'start_date': start_date,
|
272 |
+
'end_date': end_date
|
273 |
+
}).fetchall()
|
274 |
+
|
275 |
+
# Process results
|
276 |
+
analytics = {
|
277 |
+
'daily_stats': {},
|
278 |
+
'category_stats': {},
|
279 |
+
'summary': {
|
280 |
+
'total_orders': 0,
|
281 |
+
'total_revenue': 0,
|
282 |
+
'avg_order_value': 0
|
283 |
+
}
|
284 |
+
}
|
285 |
+
|
286 |
+
for row in result:
|
287 |
+
date_str = row.order_date.isoformat()
|
288 |
+
|
289 |
+
if date_str not in analytics['daily_stats']:
|
290 |
+
analytics['daily_stats'][date_str] = {
|
291 |
+
'orders': row.total_orders,
|
292 |
+
'revenue': float(row.total_revenue),
|
293 |
+
'avg_value': float(row.avg_order_value),
|
294 |
+
'unique_tables': row.unique_tables
|
295 |
+
}
|
296 |
+
|
297 |
+
category = row.category
|
298 |
+
if category not in analytics['category_stats']:
|
299 |
+
analytics['category_stats'][category] = {
|
300 |
+
'items_sold': 0,
|
301 |
+
'revenue': 0
|
302 |
+
}
|
303 |
+
|
304 |
+
analytics['category_stats'][category]['items_sold'] += row.items_sold
|
305 |
+
|
306 |
+
return analytics
|
307 |
+
|
308 |
+
except Exception as e:
|
309 |
+
logger.error(f"Error in get_analytics_data_optimized: {str(e)}")
|
310 |
+
raise
|
311 |
+
|
312 |
+
# Create singleton instance
|
313 |
+
optimized_queries = OptimizedQueryService()
|
app/static/images/README.md
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Static Images Directory
|
2 |
+
|
3 |
+
This directory contains dish images organized by database name.
|
4 |
+
|
5 |
+
## Structure:
|
6 |
+
```
|
7 |
+
app/static/images/dishes/
|
8 |
+
├── tabble_new/ # Demo hotel images
|
9 |
+
│ ├── dish1.jpg
|
10 |
+
│ ├── dish2.jpg
|
11 |
+
│ └── ...
|
12 |
+
└── your_hotel/ # Your hotel images
|
13 |
+
├── dish1.jpg
|
14 |
+
├── dish2.jpg
|
15 |
+
└── ...
|
16 |
+
```
|
17 |
+
|
18 |
+
## Upload Guidelines:
|
19 |
+
- Use JPEG or PNG format
|
20 |
+
- Recommended size: 400x300 pixels
|
21 |
+
- Keep file size under 500KB for better performance
|
22 |
+
- Use descriptive filenames
|
23 |
+
|
24 |
+
## Access URLs:
|
25 |
+
Images can be accessed at: `/static/images/dishes/{database_name}/{filename}`
|
26 |
+
|
27 |
+
Example: `/static/images/dishes/tabble_new/chicken_biryani.jpg`
|
app/static/images/dishes/7_50050-five-minute-ice-cream-DDMFS-4x3-076-fbf49ca6248e4dceb3f43a4f02823dd9.webp
ADDED
![]() |
app/static/images/dishes/7_download.webp
ADDED
![]() |
app/tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"type": "service_account",
|
3 |
+
"project_id": "tabble-v1",
|
4 |
+
"private_key_id": "8024adcbdf26bf1cac14997f39331ee88fb00a86",
|
5 |
+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDk0yMPULQZ95J0\n8Ksg5l8mg9VcpKs+tAGSGvVrmfLDHVaVIanYGmd6yJ+mZd8ZTXf5G38IlmBzK1nV\nwGsv7WPBQPSS5wDyJgj2iULuKNO09YxFXwEwzut5iQNlw81YB0K7UkIE3Psk6DY+\naaxRgToid0T3Ppbkav0FvrbU9/3/PEoIeL/i7bBo1lDnuzsAy1cycmueWA1ZdM3v\naKU6EjlXYfbo2kZPNfHplPg32JWknQy/SPeY26aBX9O07Qh/dT/DxET/OXpWaEJz\nQhuTuPHtIdFDXjIoYDQoBAKsvzxk0LnHKZ60MhkV2qoXQjPAAwah+Ki4bPEqmGRR\nsiZgoOXxAgMBAAECggEAKlJfuWkuil/3E1BhAlKBdjzrZTlin9QIt1ZrnmVomExQ\nk4AYqwrFKVk6Z/sO/p3MqwL6JaM0fxCdSrLOUFy6dseWBKabju3YegqsmaJs95rz\nwO/fp0CaHk4oVfXXQKkFH2LJKZ+ahrq5L6V5VNDPKQlAtO77Vw9vsVSS+cRNUtug\n3a8VgrBM8hDcD3Ej78XeEHI33kOgJQ4mL9Dp8zAJFrR0X3oTAI4NtYOPcgH5l2Ng\nYXZKmigQR1jIWXb5AiKaHnFdBaatyrAvWtvT1/Zsp0NYvaRoefdcfG5KIoZoRLpb\nA7sfOp1c/JPRlye1CdwggJzORLjwSbfiBbGaDvVb7QKBgQD+JfpGP95KoEO7v7hH\nPSwb9db/dzB5/3CRbq7/G9eq0J7Zl+/UeKMfJmIHQP3p9/GvPnQHn7pih9AcoMsb\nyhURGeJ5FnLVytWwPQDat4IlyTUhqaACcElogBHcgFbgJxQLfaQiQcyR6vOjAE8L\nlFEDTMFXf8LXrlkZ0H9XtXPwRQKBgQDmfe1giRIehboRqD9eS4BQtC1L5zbQx8lW\nmj8a/QXySWBMSHFKZjWChWrg1nfsGTSWGlE4uMznP+p2nuOS6jHAATf2GJZVBVm/\nPnYfcpfHGA94a3CJVWjaTp5cE1D4FRFnUS4HgY2w0T3sD9zzU8L8XQ+X2zsTviCT\n252VmJYnvQKBgQDXWICvg7CsVQ3viSzxGBFHA9EQGAM4bEwKvtmDCil88FaZE1fB\nFhNJ8rD/an97/36HOgkA6MP6dw/NIiXXvyyImAFBDtdw9fSI57fQm8uojsv5YQxW\n5KQe6t23k/uI5TPj5Krt6AkZ3xZgGIPh0OOwQxpUNMp5DJ8s83DjdbnubQKBgQC2\nyL5qg8j+s4XvYsF+AdnsJjaVrvJld0pPh1rsCCOjFFVtqTVOjud4bl1nmCzZ6tMt\nBgnLNaIo8SL6lt5aL6bsYQsD+lOdcPTPGLWMEtASbx41nN5NypGwLhCfbCIV2n9G\ns7YQ9chrpEO65ImP3akPgK1Q++ZJrckf+FVrwOmy8QKBgBv1rjNyXm3FplH0hq44\nfRcTJorvOk2ac3Hew5Czzk6ODJovIr+GPRQ2/9wF/NMBD9bfbwcvmp7vFqkTEl5f\nD/xJOwxZ06c3LBYi9tp5yrpuiqvfNZ0dUrCEY20KUktvNxfTigEGCgTmJ8+AEPlx\nFIJyQJtUD6yk6TGx7G9wYOPT\n-----END PRIVATE KEY-----\n",
|
6 |
+
"client_email": "[email protected]",
|
7 |
+
"client_id": "116848039876810333298",
|
8 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
9 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
10 |
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
11 |
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40tabble-v1.iam.gserviceaccount.com",
|
12 |
+
"universe_domain": "googleapis.com"
|
13 |
+
}
|
app/utils/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# Utils package
|
app/utils/pdf_generator.py
ADDED
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from reportlab.lib import colors
|
2 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
3 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
4 |
+
from reportlab.lib.units import inch
|
5 |
+
from io import BytesIO
|
6 |
+
from datetime import datetime
|
7 |
+
from typing import List
|
8 |
+
|
9 |
+
def generate_bill_pdf(order, settings):
|
10 |
+
"""
|
11 |
+
Generate a PDF bill for a single order
|
12 |
+
|
13 |
+
Args:
|
14 |
+
order: The order object with all details
|
15 |
+
settings: The hotel settings object
|
16 |
+
|
17 |
+
Returns:
|
18 |
+
BytesIO: A buffer containing the PDF data
|
19 |
+
"""
|
20 |
+
# Convert single order to list and use the multi-order function
|
21 |
+
return generate_multi_order_bill_pdf([order], settings)
|
22 |
+
|
23 |
+
def generate_multi_order_bill_pdf(orders: List, settings):
|
24 |
+
"""
|
25 |
+
Generate a PDF bill for multiple orders in a receipt-like format
|
26 |
+
|
27 |
+
Args:
|
28 |
+
orders: List of order objects with all details
|
29 |
+
settings: The hotel settings object
|
30 |
+
|
31 |
+
Returns:
|
32 |
+
BytesIO: A buffer containing the PDF data
|
33 |
+
"""
|
34 |
+
buffer = BytesIO()
|
35 |
+
# Use a narrower page size to mimic a receipt
|
36 |
+
doc = SimpleDocTemplate(
|
37 |
+
buffer,
|
38 |
+
pagesize=(4*inch, 11*inch), # Typical receipt width
|
39 |
+
rightMargin=10,
|
40 |
+
leftMargin=10,
|
41 |
+
topMargin=10,
|
42 |
+
bottomMargin=10
|
43 |
+
)
|
44 |
+
|
45 |
+
# Create styles
|
46 |
+
styles = getSampleStyleSheet()
|
47 |
+
styles.add(ParagraphStyle(
|
48 |
+
name='HotelName',
|
49 |
+
fontName='Helvetica-Bold',
|
50 |
+
fontSize=14,
|
51 |
+
alignment=1, # Center alignment
|
52 |
+
spaceAfter=2
|
53 |
+
))
|
54 |
+
styles.add(ParagraphStyle(
|
55 |
+
name='HotelTagline',
|
56 |
+
fontName='Helvetica',
|
57 |
+
fontSize=9,
|
58 |
+
alignment=1, # Center alignment
|
59 |
+
spaceAfter=2
|
60 |
+
))
|
61 |
+
styles.add(ParagraphStyle(
|
62 |
+
name='HotelAddress',
|
63 |
+
fontName='Helvetica',
|
64 |
+
fontSize=8,
|
65 |
+
alignment=1, # Center alignment
|
66 |
+
spaceAfter=1
|
67 |
+
))
|
68 |
+
styles.add(ParagraphStyle(
|
69 |
+
name='BillInfo',
|
70 |
+
fontName='Helvetica',
|
71 |
+
fontSize=8,
|
72 |
+
alignment=0, # Left alignment
|
73 |
+
spaceAfter=1
|
74 |
+
))
|
75 |
+
styles.add(ParagraphStyle(
|
76 |
+
name='BillInfoRight',
|
77 |
+
fontName='Helvetica',
|
78 |
+
fontSize=8,
|
79 |
+
alignment=2, # Right alignment
|
80 |
+
spaceAfter=1
|
81 |
+
))
|
82 |
+
styles.add(ParagraphStyle(
|
83 |
+
name='TableHeader',
|
84 |
+
fontName='Helvetica-Bold',
|
85 |
+
fontSize=8,
|
86 |
+
alignment=0
|
87 |
+
))
|
88 |
+
styles.add(ParagraphStyle(
|
89 |
+
name='ItemName',
|
90 |
+
fontName='Helvetica',
|
91 |
+
fontSize=8,
|
92 |
+
alignment=0
|
93 |
+
))
|
94 |
+
styles.add(ParagraphStyle(
|
95 |
+
name='ItemValue',
|
96 |
+
fontName='Helvetica',
|
97 |
+
fontSize=8,
|
98 |
+
alignment=2 # Right alignment
|
99 |
+
))
|
100 |
+
styles.add(ParagraphStyle(
|
101 |
+
name='Total',
|
102 |
+
fontName='Helvetica-Bold',
|
103 |
+
fontSize=9,
|
104 |
+
alignment=1 # Center alignment
|
105 |
+
))
|
106 |
+
styles.add(ParagraphStyle(
|
107 |
+
name='Footer',
|
108 |
+
fontName='Helvetica',
|
109 |
+
fontSize=7,
|
110 |
+
alignment=1, # Center alignment
|
111 |
+
textColor=colors.black
|
112 |
+
))
|
113 |
+
|
114 |
+
# Create content elements
|
115 |
+
elements = []
|
116 |
+
|
117 |
+
# We're not using the logo in this receipt-style bill
|
118 |
+
# Add hotel name and info
|
119 |
+
elements.append(Paragraph(settings.hotel_name.upper(), styles['HotelName']))
|
120 |
+
|
121 |
+
# Add tagline (if any, otherwise use a default)
|
122 |
+
tagline = "AN AUTHENTIC CUISINE SINCE 2000"
|
123 |
+
elements.append(Paragraph(tagline, styles['HotelTagline']))
|
124 |
+
|
125 |
+
# Add address with formatting similar to the image
|
126 |
+
if settings.address:
|
127 |
+
elements.append(Paragraph(settings.address, styles['HotelAddress']))
|
128 |
+
|
129 |
+
# Add contact info
|
130 |
+
if settings.contact_number:
|
131 |
+
elements.append(Paragraph(f"Contact: {settings.contact_number}", styles['HotelAddress']))
|
132 |
+
|
133 |
+
# Add tax ID (GSTIN)
|
134 |
+
if settings.tax_id:
|
135 |
+
elements.append(Paragraph(f"GSTIN: {settings.tax_id}", styles['HotelAddress']))
|
136 |
+
|
137 |
+
# Add a separator line
|
138 |
+
elements.append(Paragraph("_" * 50, styles['HotelAddress']))
|
139 |
+
|
140 |
+
# Add bill details in a more receipt-like format
|
141 |
+
# Use the first order for common details
|
142 |
+
first_order = orders[0]
|
143 |
+
|
144 |
+
# Create a table for the bill header info
|
145 |
+
# Get customer name if available
|
146 |
+
customer_name = ""
|
147 |
+
if hasattr(first_order, 'person_name') and first_order.person_name:
|
148 |
+
customer_name = first_order.person_name
|
149 |
+
|
150 |
+
bill_info_data = [
|
151 |
+
["Name:", customer_name],
|
152 |
+
[f"Date: {datetime.now().strftime('%d/%m/%y')}", f"Dine In: {first_order.table_number}"],
|
153 |
+
[f"{datetime.now().strftime('%H:%M')}", f"Bill No.: {first_order.id}"]
|
154 |
+
]
|
155 |
+
|
156 |
+
bill_info_table = Table(bill_info_data, colWidths=[doc.width/2-20, doc.width/2-20])
|
157 |
+
bill_info_table.setStyle(TableStyle([
|
158 |
+
('FONT', (0, 0), (-1, -1), 'Helvetica', 8),
|
159 |
+
('ALIGN', (0, 0), (0, -1), 'LEFT'),
|
160 |
+
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
161 |
+
('LINEBELOW', (0, 0), (1, 0), 0.5, colors.black),
|
162 |
+
]))
|
163 |
+
|
164 |
+
elements.append(bill_info_table)
|
165 |
+
elements.append(Paragraph("_" * 50, styles['HotelAddress']))
|
166 |
+
|
167 |
+
# Create header for items table
|
168 |
+
items_header = [["Item", "Qty.", "Price", "Amount"]]
|
169 |
+
items_header_table = Table(items_header, colWidths=[doc.width*0.4, doc.width*0.15, doc.width*0.2, doc.width*0.25])
|
170 |
+
items_header_table.setStyle(TableStyle([
|
171 |
+
('FONT', (0, 0), (-1, -1), 'Helvetica-Bold', 8),
|
172 |
+
('ALIGN', (0, 0), (0, -1), 'LEFT'),
|
173 |
+
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
174 |
+
('LINEBELOW', (0, 0), (-1, 0), 0.5, colors.black),
|
175 |
+
]))
|
176 |
+
|
177 |
+
elements.append(items_header_table)
|
178 |
+
|
179 |
+
# Add all order items
|
180 |
+
total_items = 0
|
181 |
+
subtotal_amount = 0
|
182 |
+
total_loyalty_discount = 0
|
183 |
+
total_selection_discount = 0
|
184 |
+
grand_total = 0
|
185 |
+
|
186 |
+
for order in orders:
|
187 |
+
order_data = []
|
188 |
+
|
189 |
+
for item in order.items:
|
190 |
+
dish_name = item.dish.name if item.dish else "Unknown Dish"
|
191 |
+
price = item.dish.price if item.dish else 0
|
192 |
+
quantity = item.quantity
|
193 |
+
total = price * quantity
|
194 |
+
subtotal_amount += total
|
195 |
+
total_items += quantity
|
196 |
+
|
197 |
+
order_data.append([
|
198 |
+
dish_name,
|
199 |
+
str(quantity),
|
200 |
+
f"{price:.2f}",
|
201 |
+
f"{total:.2f}"
|
202 |
+
])
|
203 |
+
|
204 |
+
# Accumulate discount amounts from order records
|
205 |
+
if hasattr(order, 'loyalty_discount_amount') and order.loyalty_discount_amount:
|
206 |
+
total_loyalty_discount += order.loyalty_discount_amount
|
207 |
+
if hasattr(order, 'selection_offer_discount_amount') and order.selection_offer_discount_amount:
|
208 |
+
total_selection_discount += order.selection_offer_discount_amount
|
209 |
+
|
210 |
+
# Use stored total_amount if available, otherwise calculate from subtotal
|
211 |
+
if hasattr(order, 'total_amount') and order.total_amount is not None:
|
212 |
+
grand_total += order.total_amount
|
213 |
+
else:
|
214 |
+
# Fallback to original calculation if no stored total
|
215 |
+
grand_total += subtotal_amount
|
216 |
+
|
217 |
+
# Create the table for this order's items
|
218 |
+
if order_data:
|
219 |
+
items_table = Table(order_data, colWidths=[doc.width*0.4, doc.width*0.15, doc.width*0.2, doc.width*0.25])
|
220 |
+
items_table.setStyle(TableStyle([
|
221 |
+
('FONT', (0, 0), (-1, -1), 'Helvetica', 8),
|
222 |
+
('ALIGN', (0, 0), (0, -1), 'LEFT'),
|
223 |
+
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
224 |
+
]))
|
225 |
+
|
226 |
+
elements.append(items_table)
|
227 |
+
|
228 |
+
# Add a separator line
|
229 |
+
elements.append(Paragraph("_" * 50, styles['HotelAddress']))
|
230 |
+
|
231 |
+
# Add totals section with discounts
|
232 |
+
totals_data = [
|
233 |
+
[f"Total Qty: {total_items}", f"Sub Total", f"${subtotal_amount:.2f}"],
|
234 |
+
]
|
235 |
+
|
236 |
+
# Add loyalty discount if applicable
|
237 |
+
if total_loyalty_discount > 0:
|
238 |
+
totals_data.append(["", f"Loyalty Discount", f"-${total_loyalty_discount:.2f}"])
|
239 |
+
|
240 |
+
# Add selection offer discount if applicable
|
241 |
+
if total_selection_discount > 0:
|
242 |
+
totals_data.append(["", f"Offer Discount", f"-${total_selection_discount:.2f}"])
|
243 |
+
|
244 |
+
# Calculate amount after discounts
|
245 |
+
amount_after_discounts = subtotal_amount - total_loyalty_discount - total_selection_discount
|
246 |
+
|
247 |
+
# Calculate tax on discounted amount (assuming 5% CGST and 5% SGST)
|
248 |
+
tax_rate = 0.05 # 5%
|
249 |
+
cgst = amount_after_discounts * tax_rate
|
250 |
+
sgst = amount_after_discounts * tax_rate
|
251 |
+
|
252 |
+
# Add tax lines
|
253 |
+
totals_data.extend([
|
254 |
+
["", f"CGST (5%)", f"${cgst:.2f}"],
|
255 |
+
["", f"SGST (5%)", f"${sgst:.2f}"],
|
256 |
+
])
|
257 |
+
|
258 |
+
# Calculate final total including tax
|
259 |
+
final_total = amount_after_discounts + cgst + sgst
|
260 |
+
|
261 |
+
totals_table = Table(totals_data, colWidths=[doc.width*0.4, doc.width*0.35, doc.width*0.25])
|
262 |
+
totals_table.setStyle(TableStyle([
|
263 |
+
('FONT', (0, 0), (-1, -1), 'Helvetica', 8),
|
264 |
+
('ALIGN', (0, 0), (0, -1), 'LEFT'),
|
265 |
+
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
266 |
+
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
|
267 |
+
]))
|
268 |
+
|
269 |
+
elements.append(totals_table)
|
270 |
+
|
271 |
+
# Add grand total with emphasis
|
272 |
+
elements.append(Paragraph("_" * 50, styles['HotelAddress']))
|
273 |
+
elements.append(Paragraph(f"Grand Total ${final_total:.2f}", styles['Total']))
|
274 |
+
elements.append(Paragraph("_" * 50, styles['HotelAddress']))
|
275 |
+
|
276 |
+
# Add license info and thank you message
|
277 |
+
elements.append(Spacer(1, 5))
|
278 |
+
elements.append(Paragraph("FSSAI Lic No: 12018033000205", styles['Footer']))
|
279 |
+
elements.append(Paragraph("!!! Thank You !!! Visit Again !!!", styles['Footer']))
|
280 |
+
|
281 |
+
# Build the PDF
|
282 |
+
doc.build(elements)
|
283 |
+
buffer.seek(0)
|
284 |
+
|
285 |
+
return buffer
|
hotels.csv
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
hotel_name,password,hotel_id
|
2 |
+
tabble_new,myhotel,1
|
3 |
+
anifa,anifa123,2
|
4 |
+
hotelgood,hotelgood123,3
|
5 |
+
hotelmoon,moon123,4
|
6 |
+
shine,shine123,5
|
init_db.py
ADDED
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from app.database import (
|
2 |
+
create_tables,
|
3 |
+
SessionLocal,
|
4 |
+
Dish,
|
5 |
+
Person,
|
6 |
+
Base,
|
7 |
+
LoyaltyProgram,
|
8 |
+
SelectionOffer,
|
9 |
+
Table,
|
10 |
+
)
|
11 |
+
from sqlalchemy import create_engine
|
12 |
+
from datetime import datetime, timezone
|
13 |
+
import os
|
14 |
+
import sys
|
15 |
+
|
16 |
+
|
17 |
+
def init_db(force_reset=False):
|
18 |
+
# Check if force_reset is enabled
|
19 |
+
if force_reset:
|
20 |
+
# Drop all tables and recreate them
|
21 |
+
print("Forcing database reset...")
|
22 |
+
Base.metadata.drop_all(
|
23 |
+
bind=create_engine(
|
24 |
+
"sqlite:///./tabble_new.db", connect_args={"check_same_thread": False}
|
25 |
+
)
|
26 |
+
)
|
27 |
+
|
28 |
+
# Create tables
|
29 |
+
create_tables()
|
30 |
+
|
31 |
+
# Create a database session
|
32 |
+
db = SessionLocal()
|
33 |
+
|
34 |
+
# Check if dishes already exist
|
35 |
+
existing_dishes = db.query(Dish).count()
|
36 |
+
if existing_dishes > 0:
|
37 |
+
print("Database already contains data. Skipping initialization.")
|
38 |
+
return
|
39 |
+
|
40 |
+
# Add sample dishes
|
41 |
+
sample_dishes = [
|
42 |
+
# Regular dishes
|
43 |
+
Dish(
|
44 |
+
name="Margherita Pizza",
|
45 |
+
description="Classic pizza with tomato sauce, mozzarella, and basil",
|
46 |
+
category='["Main Course", "Italian"]',
|
47 |
+
price=12.99,
|
48 |
+
quantity=20,
|
49 |
+
image_path="/static/images/default-dish.jpg",
|
50 |
+
discount=0,
|
51 |
+
is_offer=0,
|
52 |
+
is_special=0,
|
53 |
+
is_vegetarian=1,
|
54 |
+
),
|
55 |
+
Dish(
|
56 |
+
name="Caesar Salad",
|
57 |
+
description="Fresh romaine lettuce with Caesar dressing, croutons, and parmesan",
|
58 |
+
category='["Appetizer", "Salad"]',
|
59 |
+
price=8.99,
|
60 |
+
quantity=15,
|
61 |
+
image_path="/static/images/default-dish.jpg",
|
62 |
+
discount=0,
|
63 |
+
is_offer=0,
|
64 |
+
is_special=0,
|
65 |
+
is_vegetarian=1,
|
66 |
+
),
|
67 |
+
Dish(
|
68 |
+
name="Chocolate Cake",
|
69 |
+
description="Rich chocolate cake with ganache frosting",
|
70 |
+
category="Dessert",
|
71 |
+
price=6.99,
|
72 |
+
quantity=10,
|
73 |
+
image_path="/static/images/default-dish.jpg",
|
74 |
+
discount=0,
|
75 |
+
is_offer=0,
|
76 |
+
is_special=0,
|
77 |
+
),
|
78 |
+
Dish(
|
79 |
+
name="Iced Tea",
|
80 |
+
description="Refreshing iced tea with lemon",
|
81 |
+
category="Beverage",
|
82 |
+
price=3.99,
|
83 |
+
quantity=30,
|
84 |
+
image_path="/static/images/default-dish.jpg",
|
85 |
+
discount=0,
|
86 |
+
is_offer=0,
|
87 |
+
is_special=0,
|
88 |
+
),
|
89 |
+
Dish(
|
90 |
+
name="Chicken Alfredo",
|
91 |
+
description="Fettuccine pasta with creamy Alfredo sauce and grilled chicken",
|
92 |
+
category="Main Course",
|
93 |
+
price=15.99,
|
94 |
+
quantity=12,
|
95 |
+
image_path="/static/images/default-dish.jpg",
|
96 |
+
discount=0,
|
97 |
+
is_offer=0,
|
98 |
+
),
|
99 |
+
Dish(
|
100 |
+
name="Garlic Bread",
|
101 |
+
description="Toasted bread with garlic butter and herbs",
|
102 |
+
category="Appetizer",
|
103 |
+
price=4.99,
|
104 |
+
quantity=25,
|
105 |
+
image_path="/static/images/default-dish.jpg",
|
106 |
+
discount=0,
|
107 |
+
is_offer=0,
|
108 |
+
is_special=0,
|
109 |
+
),
|
110 |
+
# Special offer dishes
|
111 |
+
Dish(
|
112 |
+
name="Weekend Special Pizza",
|
113 |
+
description="Deluxe pizza with premium toppings and extra cheese",
|
114 |
+
category="Main Course",
|
115 |
+
price=18.99,
|
116 |
+
quantity=15,
|
117 |
+
image_path="/static/images/default-dish.jpg",
|
118 |
+
discount=20,
|
119 |
+
is_offer=1,
|
120 |
+
is_special=0,
|
121 |
+
),
|
122 |
+
Dish(
|
123 |
+
name="Seafood Pasta",
|
124 |
+
description="Fresh pasta with mixed seafood in a creamy sauce",
|
125 |
+
category="Main Course",
|
126 |
+
price=22.99,
|
127 |
+
quantity=10,
|
128 |
+
image_path="/static/images/default-dish.jpg",
|
129 |
+
discount=15,
|
130 |
+
is_offer=1,
|
131 |
+
is_special=0,
|
132 |
+
),
|
133 |
+
Dish(
|
134 |
+
name="Tiramisu",
|
135 |
+
description="Classic Italian dessert with coffee-soaked ladyfingers and mascarpone cream",
|
136 |
+
category="Dessert",
|
137 |
+
price=9.99,
|
138 |
+
quantity=8,
|
139 |
+
image_path="/static/images/default-dish.jpg",
|
140 |
+
discount=25,
|
141 |
+
is_offer=1,
|
142 |
+
is_special=0,
|
143 |
+
),
|
144 |
+
# Today's special dishes
|
145 |
+
Dish(
|
146 |
+
name="Chef's Special Steak",
|
147 |
+
description="Prime cut steak cooked to perfection with special house seasoning",
|
148 |
+
category="Main Course",
|
149 |
+
price=24.99,
|
150 |
+
quantity=12,
|
151 |
+
image_path="/static/images/default-dish.jpg",
|
152 |
+
discount=0,
|
153 |
+
is_offer=0,
|
154 |
+
is_special=1,
|
155 |
+
),
|
156 |
+
Dish(
|
157 |
+
name="Truffle Mushroom Risotto",
|
158 |
+
description="Creamy risotto with wild mushrooms and truffle oil",
|
159 |
+
category="Main Course",
|
160 |
+
price=16.99,
|
161 |
+
quantity=10,
|
162 |
+
image_path="/static/images/default-dish.jpg",
|
163 |
+
discount=0,
|
164 |
+
is_offer=0,
|
165 |
+
is_special=1,
|
166 |
+
),
|
167 |
+
Dish(
|
168 |
+
name="Chocolate Lava Cake",
|
169 |
+
description="Warm chocolate cake with a molten center, served with vanilla ice cream",
|
170 |
+
category="Dessert",
|
171 |
+
price=8.99,
|
172 |
+
quantity=15,
|
173 |
+
image_path="/static/images/default-dish.jpg",
|
174 |
+
discount=0,
|
175 |
+
is_offer=0,
|
176 |
+
is_special=1,
|
177 |
+
),
|
178 |
+
]
|
179 |
+
|
180 |
+
# Add dishes to database
|
181 |
+
for dish in sample_dishes:
|
182 |
+
db.add(dish)
|
183 |
+
|
184 |
+
# Add sample users
|
185 |
+
sample_users = [
|
186 |
+
Person(
|
187 |
+
username="john_doe",
|
188 |
+
password="password123",
|
189 |
+
visit_count=1,
|
190 |
+
last_visit=datetime.now(timezone.utc),
|
191 |
+
),
|
192 |
+
Person(
|
193 |
+
username="jane_smith",
|
194 |
+
password="password456",
|
195 |
+
visit_count=3,
|
196 |
+
last_visit=datetime.now(timezone.utc),
|
197 |
+
),
|
198 |
+
Person(
|
199 |
+
username="guest",
|
200 |
+
password="guest",
|
201 |
+
visit_count=5,
|
202 |
+
last_visit=datetime.now(timezone.utc),
|
203 |
+
),
|
204 |
+
]
|
205 |
+
|
206 |
+
# Add users to database
|
207 |
+
for user in sample_users:
|
208 |
+
db.add(user)
|
209 |
+
|
210 |
+
# Add sample loyalty program tiers
|
211 |
+
sample_loyalty_tiers = [
|
212 |
+
LoyaltyProgram(
|
213 |
+
visit_count=3,
|
214 |
+
discount_percentage=5.0,
|
215 |
+
is_active=True,
|
216 |
+
created_at=datetime.now(timezone.utc),
|
217 |
+
updated_at=datetime.now(timezone.utc),
|
218 |
+
),
|
219 |
+
LoyaltyProgram(
|
220 |
+
visit_count=5,
|
221 |
+
discount_percentage=10.0,
|
222 |
+
is_active=True,
|
223 |
+
created_at=datetime.now(timezone.utc),
|
224 |
+
updated_at=datetime.now(timezone.utc),
|
225 |
+
),
|
226 |
+
LoyaltyProgram(
|
227 |
+
visit_count=10,
|
228 |
+
discount_percentage=15.0,
|
229 |
+
is_active=True,
|
230 |
+
created_at=datetime.now(timezone.utc),
|
231 |
+
updated_at=datetime.now(timezone.utc),
|
232 |
+
),
|
233 |
+
LoyaltyProgram(
|
234 |
+
visit_count=20,
|
235 |
+
discount_percentage=20.0,
|
236 |
+
is_active=True,
|
237 |
+
created_at=datetime.now(timezone.utc),
|
238 |
+
updated_at=datetime.now(timezone.utc),
|
239 |
+
),
|
240 |
+
]
|
241 |
+
|
242 |
+
# Add loyalty tiers to database
|
243 |
+
for tier in sample_loyalty_tiers:
|
244 |
+
db.add(tier)
|
245 |
+
|
246 |
+
# Add sample selection offers
|
247 |
+
sample_selection_offers = [
|
248 |
+
SelectionOffer(
|
249 |
+
min_amount=50.0,
|
250 |
+
discount_amount=5.0,
|
251 |
+
is_active=True,
|
252 |
+
description="Spend $50, get $5 off",
|
253 |
+
created_at=datetime.now(timezone.utc),
|
254 |
+
updated_at=datetime.now(timezone.utc),
|
255 |
+
),
|
256 |
+
SelectionOffer(
|
257 |
+
min_amount=100.0,
|
258 |
+
discount_amount=15.0,
|
259 |
+
is_active=True,
|
260 |
+
description="Spend $100, get $15 off",
|
261 |
+
created_at=datetime.now(timezone.utc),
|
262 |
+
updated_at=datetime.now(timezone.utc),
|
263 |
+
),
|
264 |
+
SelectionOffer(
|
265 |
+
min_amount=150.0,
|
266 |
+
discount_amount=25.0,
|
267 |
+
is_active=True,
|
268 |
+
description="Spend $150, get $25 off",
|
269 |
+
created_at=datetime.now(timezone.utc),
|
270 |
+
updated_at=datetime.now(timezone.utc),
|
271 |
+
),
|
272 |
+
]
|
273 |
+
|
274 |
+
# Add selection offers to database
|
275 |
+
for offer in sample_selection_offers:
|
276 |
+
db.add(offer)
|
277 |
+
|
278 |
+
# Add sample tables
|
279 |
+
sample_tables = [
|
280 |
+
Table(
|
281 |
+
table_number=1,
|
282 |
+
is_occupied=False,
|
283 |
+
created_at=datetime.now(timezone.utc),
|
284 |
+
updated_at=datetime.now(timezone.utc),
|
285 |
+
),
|
286 |
+
Table(
|
287 |
+
table_number=2,
|
288 |
+
is_occupied=False,
|
289 |
+
created_at=datetime.now(timezone.utc),
|
290 |
+
updated_at=datetime.now(timezone.utc),
|
291 |
+
),
|
292 |
+
Table(
|
293 |
+
table_number=3,
|
294 |
+
is_occupied=False,
|
295 |
+
created_at=datetime.now(timezone.utc),
|
296 |
+
updated_at=datetime.now(timezone.utc),
|
297 |
+
),
|
298 |
+
Table(
|
299 |
+
table_number=4,
|
300 |
+
is_occupied=False,
|
301 |
+
created_at=datetime.now(timezone.utc),
|
302 |
+
updated_at=datetime.now(timezone.utc),
|
303 |
+
),
|
304 |
+
Table(
|
305 |
+
table_number=5,
|
306 |
+
is_occupied=False,
|
307 |
+
created_at=datetime.now(timezone.utc),
|
308 |
+
updated_at=datetime.now(timezone.utc),
|
309 |
+
),
|
310 |
+
Table(
|
311 |
+
table_number=6,
|
312 |
+
is_occupied=False,
|
313 |
+
created_at=datetime.now(timezone.utc),
|
314 |
+
updated_at=datetime.now(timezone.utc),
|
315 |
+
),
|
316 |
+
Table(
|
317 |
+
table_number=7,
|
318 |
+
is_occupied=False,
|
319 |
+
created_at=datetime.now(timezone.utc),
|
320 |
+
updated_at=datetime.now(timezone.utc),
|
321 |
+
),
|
322 |
+
Table(
|
323 |
+
table_number=8,
|
324 |
+
is_occupied=False,
|
325 |
+
created_at=datetime.now(timezone.utc),
|
326 |
+
updated_at=datetime.now(timezone.utc),
|
327 |
+
),
|
328 |
+
]
|
329 |
+
|
330 |
+
# Add tables to database
|
331 |
+
for table in sample_tables:
|
332 |
+
db.add(table)
|
333 |
+
|
334 |
+
# Commit changes
|
335 |
+
db.commit()
|
336 |
+
|
337 |
+
print("Database initialized with sample data:")
|
338 |
+
print("- Added", len(sample_dishes), "sample dishes")
|
339 |
+
print("- Added", len(sample_users), "sample users")
|
340 |
+
print("- Added", len(sample_loyalty_tiers), "loyalty program tiers")
|
341 |
+
print("- Added", len(sample_selection_offers), "selection offers")
|
342 |
+
print("- Added", len(sample_tables), "tables")
|
343 |
+
|
344 |
+
# Close session
|
345 |
+
db.close()
|
346 |
+
|
347 |
+
|
348 |
+
if __name__ == "__main__":
|
349 |
+
# Create static/images directory if it doesn't exist
|
350 |
+
os.makedirs("app/static/images", exist_ok=True)
|
351 |
+
|
352 |
+
# Check for force reset flag
|
353 |
+
force_reset = "--force-reset" in sys.argv
|
354 |
+
|
355 |
+
# Initialize database
|
356 |
+
init_db(force_reset)
|
package-lock.json
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "Tabble-v3",
|
3 |
+
"lockfileVersion": 3,
|
4 |
+
"requires": true,
|
5 |
+
"packages": {}
|
6 |
+
}
|
render.yaml
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
services:
|
2 |
+
- type: web
|
3 |
+
name: tabble-backend
|
4 |
+
env: python
|
5 |
+
buildCommand: pip install -r requirements.txt
|
6 |
+
startCommand: python start.py
|
7 |
+
envVars:
|
8 |
+
- key: RENDER
|
9 |
+
value: "true"
|
10 |
+
- key: PYTHON_VERSION
|
11 |
+
value: "3.11.9"
|
12 |
+
- key: PORT
|
13 |
+
generateValue: true
|
14 |
+
healthCheckPath: /health
|
requirements.txt
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Core FastAPI dependencies
|
2 |
+
fastapi==0.104.1
|
3 |
+
uvicorn[standard]==0.23.2
|
4 |
+
|
5 |
+
# Database
|
6 |
+
sqlalchemy==2.0.27
|
7 |
+
|
8 |
+
# Web framework utilities
|
9 |
+
python-multipart==0.0.6
|
10 |
+
jinja2==3.1.2
|
11 |
+
python-dotenv==1.0.0
|
12 |
+
|
13 |
+
# PDF and image processing
|
14 |
+
reportlab==4.0.7
|
15 |
+
pillow==10.1.0
|
16 |
+
|
17 |
+
# Firebase authentication
|
18 |
+
firebase-admin
|
19 |
+
|
20 |
+
# Additional dependencies for production
|
21 |
+
requests==2.31.0
|
22 |
+
aiofiles==23.2.1
|
run.py
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uvicorn
|
2 |
+
import os
|
3 |
+
import socket
|
4 |
+
|
5 |
+
|
6 |
+
def get_ip_address():
|
7 |
+
"""Get the local IP address of the machine."""
|
8 |
+
try:
|
9 |
+
# Create a socket connection to an external server
|
10 |
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
11 |
+
# Doesn't need to be reachable
|
12 |
+
s.connect(("8.8.8.8", 80))
|
13 |
+
ip_address = s.getsockname()[0]
|
14 |
+
s.close()
|
15 |
+
return ip_address
|
16 |
+
except Exception as e:
|
17 |
+
print(f"Error getting IP address: {e}")
|
18 |
+
return "127.0.0.1" # Return localhost if there's an error
|
19 |
+
|
20 |
+
|
21 |
+
if __name__ == "__main__":
|
22 |
+
# Create static/images directory if it doesn't exist
|
23 |
+
os.makedirs("app/static/images", exist_ok=True)
|
24 |
+
|
25 |
+
# Check for force reset flag
|
26 |
+
|
27 |
+
# Get the IP address
|
28 |
+
ip_address = get_ip_address()
|
29 |
+
|
30 |
+
# Display access information
|
31 |
+
print("\n" + "=" * 50)
|
32 |
+
|
33 |
+
print(f"Access from other devices at: http://{ip_address}:8000")
|
34 |
+
print("=" * 50 + "\n")
|
35 |
+
|
36 |
+
# Get port from environment variable (for Render deployment) or default to 8000
|
37 |
+
port = int(os.environ.get("PORT", 8000))
|
38 |
+
|
39 |
+
# Check if running in production (Render sets this)
|
40 |
+
is_production = os.environ.get("RENDER") is not None
|
41 |
+
|
42 |
+
if is_production:
|
43 |
+
print(f"Starting production server on port {port}")
|
44 |
+
# Production mode - no reload, bind to 0.0.0.0
|
45 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=False)
|
46 |
+
else:
|
47 |
+
# Development mode - Run the application on your IP address
|
48 |
+
# Using 0.0.0.0 allows connections from any IP
|
49 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True)
|