Shyamnath commited on
Commit
90537f3
·
1 Parent(s): 6dc647c

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
Files changed (50) hide show
  1. .dockerignore +73 -0
  2. .env.example +33 -0
  3. .gitattributes +0 -35
  4. .gitignore +68 -0
  5. DEMO_MODE_INSTRUCTIONS.md +139 -0
  6. DEPLOYMENT_SUMMARY.md +139 -0
  7. Dockerfile +44 -0
  8. HUGGINGFACE_DEPLOYMENT_GUIDE.md +93 -0
  9. README.md +712 -5
  10. README_HUGGINGFACE.md +17 -0
  11. TABLE_MANAGEMENT_IMPLEMENTATION.md +162 -0
  12. app.py +47 -0
  13. app/__init__.py +8 -0
  14. app/database.py +473 -0
  15. app/main.py +116 -0
  16. app/middleware/__init__.py +3 -0
  17. app/middleware/session_middleware.py +125 -0
  18. app/models/database_config.py +21 -0
  19. app/models/dish.py +42 -0
  20. app/models/feedback.py +22 -0
  21. app/models/loyalty.py +28 -0
  22. app/models/order.py +65 -0
  23. app/models/selection_offer.py +30 -0
  24. app/models/settings.py +34 -0
  25. app/models/table.py +33 -0
  26. app/models/user.py +39 -0
  27. app/routers/admin.py +643 -0
  28. app/routers/analytics.py +573 -0
  29. app/routers/chef.py +93 -0
  30. app/routers/customer.py +728 -0
  31. app/routers/feedback.py +87 -0
  32. app/routers/loyalty.py +172 -0
  33. app/routers/selection_offer.py +198 -0
  34. app/routers/settings.py +207 -0
  35. app/routers/table.py +254 -0
  36. app/services/__init__.py +1 -0
  37. app/services/firebase_auth.py +100 -0
  38. app/services/optimized_queries.py +313 -0
  39. app/static/images/README.md +27 -0
  40. app/static/images/dishes/7_50050-five-minute-ice-cream-DDMFS-4x3-076-fbf49ca6248e4dceb3f43a4f02823dd9.webp +0 -0
  41. app/static/images/dishes/7_download.webp +0 -0
  42. app/tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json +13 -0
  43. app/utils/__init__.py +1 -0
  44. app/utils/pdf_generator.py +285 -0
  45. hotels.csv +6 -0
  46. init_db.py +356 -0
  47. package-lock.json +6 -0
  48. render.yaml +14 -0
  49. requirements.txt +22 -0
  50. 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: Tableeee V3
3
- emoji: 📚
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
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
+ [![Deploy on Hugging Face Spaces](https://huggingface.co/datasets/huggingface/badges/raw/main/deploy-on-spaces-md.svg)](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)