05/06/2026 Initial commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=change-me-to-a-random-256-bit-key
|
||||||
|
ADMIN_SECRET_KEY=separate-key-for-admin-portal-sessions
|
||||||
|
DATABASE_URL=mysql+pymysql://salon_pos_app:password@localhost/salon_pos
|
||||||
|
MAIL_SERVER=smtp.example.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=true
|
||||||
|
MAIL_USERNAME=noreply@mydomain.com
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
|
||||||
|
ADMIN_DOMAIN=admin.mydomain.com
|
||||||
|
TENANT_DOMAIN=mydomain.com
|
||||||
|
DEMO_TENANT_SLUG=demo
|
||||||
|
BACKUP_DIR=/var/backups/salon_pos
|
||||||
|
BACKUP_RETAIN_DAYS=30
|
||||||
|
SESSION_TIMEOUT_TENANT=1800
|
||||||
|
SESSION_TIMEOUT_ADMIN=3600
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES=900
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES=604800
|
||||||
|
MAX_LOGIN_ATTEMPTS=5
|
||||||
|
LOGIN_LOCKOUT_MINUTES=15
|
||||||
|
STAFF_PASSCODE_MIN_LENGTH=4
|
||||||
|
STAFF_PASSCODE_MAX_LENGTH=6
|
||||||
@@ -82,7 +82,7 @@ The **Customer Check-In Kiosk** (`mydomain.com/checkin/{tenant_slug}`) is a sepa
|
|||||||
- No access to other staff’s data, full customer PII, reports, inventory, or any settings
|
- No access to other staff’s data, full customer PII, reports, inventory, or any settings
|
||||||
- **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge
|
- **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge
|
||||||
- **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously)
|
- **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously)
|
||||||
- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and a real-time alert is pushed to the receptionist’s dashboard; the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration
|
- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and an alert is surfaced on the receptionist’s dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`); the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration
|
||||||
- **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes
|
- **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes
|
||||||
- **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans
|
- **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans
|
||||||
- **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase
|
- **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase
|
||||||
@@ -671,7 +671,7 @@ WantedBy=multi-user.target
|
|||||||
- [ ] Promotions management (create/edit/deactivate promotions; percentage-off; date range; target specific or all services/products)
|
- [ ] Promotions management (create/edit/deactivate promotions; percentage-off; date range; target specific or all services/products)
|
||||||
- [ ] Promotion engine: `get_active_promotion(item_id, item_type)` helper — called at checkout to resolve applicable promotion for each line item automatically
|
- [ ] Promotion engine: `get_active_promotion(item_id, item_type)` helper — called at checkout to resolve applicable promotion for each line item automatically
|
||||||
- [ ] Appointments (calendar, walk-in flag, status workflow, cancellation + no-show capture; no-show count incremented on customer record)
|
- [ ] Appointments (calendar, walk-in flag, status workflow, cancellation + no-show capture; no-show count incremented on customer record)
|
||||||
- [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; real-time dashboard alert to receptionist; auto-reset timer; rate limiting; `service_requested` selector)
|
- [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; receptionist alert via 5-second polling (`GET /api/v1/checkin/queue?status=waiting`); auto-reset timer; rate limiting; `service_requested` selector)
|
||||||
- [ ] Online customer booking (`/book/{slug}`; no auth; availability check; confirmation email; auto-confirm toggle)
|
- [ ] Online customer booking (`/book/{slug}`; no auth; availability check; confirmation email; auto-confirm toggle)
|
||||||
- [ ] Waitlist (join when slot full; auto-notify on cancellation; queue management in calendar)
|
- [ ] Waitlist (join when slot full; auto-notify on cancellation; queue management in calendar)
|
||||||
- [ ] Staff Portal — `/staff/portal` (personal schedule, upcoming appointments, clock-in/clock-out, commission summary, payment history, read-only profile)
|
- [ ] Staff Portal — `/staff/portal` (personal schedule, upcoming appointments, clock-in/clock-out, commission summary, payment history, read-only profile)
|
||||||
@@ -764,7 +764,7 @@ WantedBy=multi-user.target
|
|||||||
| Location isolation | `g.location` validated against `staff_locations` for `tenant_staff` role; 403 on violation |
|
| Location isolation | `g.location` validated against `staff_locations` for `tenant_staff` role; 403 on violation |
|
||||||
| Feature gating | `@tenant_feature_required('flag')` decorator; flags from `plans.features_json` |
|
| Feature gating | `@tenant_feature_required('flag')` decorator; flags from `plans.features_json` |
|
||||||
| Staff portal isolation | `tenant_staff` sessions routed exclusively to `/staff/portal/*`; `@require_role` blocks all owner/manager routes; staff cannot access other staff members' data |
|
| Staff portal isolation | `tenant_staff` sessions routed exclusively to `/staff/portal/*`; `@require_role` blocks all owner/manager routes; staff cannot access other staff members' data |
|
||||||
| Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup |
|
| Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup; receptionist dashboard alert delivered via 5-second JS polling (`GET /api/v1/checkin/queue?status=waiting`) — no WebSockets or SSE required |
|
||||||
| Demo read-only | `@demo_readonly` decorator returns 403 on any write operation for demo tenant |
|
| Demo read-only | `@demo_readonly` decorator returns 403 on any write operation for demo tenant |
|
||||||
| Superadmin IP gate | Nginx `allow`/`deny` + Flask checks `ADMIN_IP_ALLOWLIST` for double enforcement |
|
| Superadmin IP gate | Nginx `allow`/`deny` + Flask checks `ADMIN_IP_ALLOWLIST` for double enforcement |
|
||||||
|
|
||||||
@@ -883,5 +883,5 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `
|
|||||||
| 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. |
|
| 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. |
|
||||||
| 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. |
|
| 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. |
|
||||||
| 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. |
|
| 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. |
|
||||||
| 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and pushes a real-time alert to the receptionist dashboard. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. |
|
| 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and surfaces an alert on the receptionist dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. |
|
||||||
| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |
|
| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |
|
||||||
@@ -1,3 +1,292 @@
|
|||||||
# MyPOS
|
# Salon POS — Multi-Tenant SaaS
|
||||||
|
|
||||||
POS system for Nails Salon
|
## Overview
|
||||||
|
|
||||||
|
A multi-tenant SaaS POS web application for nail salons, served across two dedicated domains:
|
||||||
|
|
||||||
|
| Domain | Audience | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin.mydomain.com` | System Admins | Platform management |
|
||||||
|
| `mydomain.com` | Tenant users | Salon management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Python 3.11+ / Flask
|
||||||
|
- **Database:** MySQL 8.0+ with SQLAlchemy ORM
|
||||||
|
- **Auth:** Flask-Login (UI) + Flask-JWT-Extended (API)
|
||||||
|
- **Frontend:** Jinja2 + Bootstrap 5 + Vanilla JS
|
||||||
|
- **WSGI:** Gunicorn (2 independent processes)
|
||||||
|
- **Reverse Proxy:** Nginx
|
||||||
|
- **Scheduler:** APScheduler
|
||||||
|
- **Process Manager:** systemd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fresh Install (Production)
|
||||||
|
|
||||||
|
### 1. System dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install -y python3.11 python3.11-venv python3-pip \
|
||||||
|
mysql-server nginx libmysqlclient-dev build-essential libssl-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Application user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -m -s /bin/bash salonpos
|
||||||
|
sudo mkdir -p /opt/salon_pos
|
||||||
|
sudo chown salonpos:salonpos /opt/salon_pos
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Clone and set up virtual environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/salon_pos
|
||||||
|
python3.11 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Environment configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
# Edit .env with your values — SECRET_KEY, ADMIN_SECRET_KEY, DATABASE_URL, MAIL_*, etc.
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. MySQL setup
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE salon_pos CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'salon_pos_app'@'localhost' IDENTIFIED BY 'strong-password-here';
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP
|
||||||
|
ON salon_pos.* TO 'salon_pos_app'@'localhost';
|
||||||
|
|
||||||
|
-- Backup user (read-only)
|
||||||
|
CREATE USER 'salon_pos_backup'@'localhost' IDENTIFIED BY 'backup-password-here';
|
||||||
|
GRANT SELECT, LOCK TABLES ON salon_pos.* TO 'salon_pos_backup'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
Store backup credentials in `/etc/mysql/backup.cnf` (mode 600):
|
||||||
|
```ini
|
||||||
|
[client]
|
||||||
|
user=salon_pos_backup
|
||||||
|
password=backup-password-here
|
||||||
|
host=localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Database migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/salon_pos
|
||||||
|
source venv/bin/activate
|
||||||
|
flask --app wsgi_tenant:app db init # First time only
|
||||||
|
flask --app wsgi_tenant:app db migrate -m "Phase 1 — initial schema"
|
||||||
|
flask --app wsgi_tenant:app db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Seed initial data (plans + superadmin)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app wsgi_admin:app shell
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
from app.models.platform import Plan, SystemUser
|
||||||
|
|
||||||
|
# Create subscription plans
|
||||||
|
plans = [
|
||||||
|
Plan(name="Starter", price_monthly=29.00, max_staff=3, max_locations=1,
|
||||||
|
features_json={"pos": True, "appointments": True, "customers": True,
|
||||||
|
"services": True, "promotions": True,
|
||||||
|
"appointment_reminders": True, "customer_reviews": True,
|
||||||
|
"reconciliation": True, "basic_reports": True,
|
||||||
|
"inventory": False, "commission": False,
|
||||||
|
"full_reports": False, "multi_location": False,
|
||||||
|
"online_booking": False, "waitlist": False,
|
||||||
|
"marketing": False}),
|
||||||
|
Plan(name="Growth", price_monthly=59.00, max_staff=10, max_locations=3,
|
||||||
|
features_json={"pos": True, "appointments": True, "customers": True,
|
||||||
|
"services": True, "promotions": True,
|
||||||
|
"appointment_reminders": True, "customer_reviews": True,
|
||||||
|
"reconciliation": True, "basic_reports": True,
|
||||||
|
"inventory": True, "commission": True,
|
||||||
|
"full_reports": True, "multi_location": True,
|
||||||
|
"online_booking": True, "waitlist": True,
|
||||||
|
"marketing": False}),
|
||||||
|
Plan(name="Pro", price_monthly=99.00, max_staff=None, max_locations=None,
|
||||||
|
features_json={"pos": True, "appointments": True, "customers": True,
|
||||||
|
"services": True, "promotions": True,
|
||||||
|
"appointment_reminders": True, "customer_reviews": True,
|
||||||
|
"reconciliation": True, "basic_reports": True,
|
||||||
|
"inventory": True, "commission": True,
|
||||||
|
"full_reports": True, "multi_location": True,
|
||||||
|
"online_booking": True, "waitlist": True,
|
||||||
|
"marketing": True}),
|
||||||
|
]
|
||||||
|
db.session.add_all(plans)
|
||||||
|
|
||||||
|
# Create first superadmin
|
||||||
|
admin = SystemUser(
|
||||||
|
email="admin@yourdomain.com",
|
||||||
|
password_hash=bcrypt.generate_password_hash("ChangeMe1!").decode("utf-8"),
|
||||||
|
name="System Admin",
|
||||||
|
role="superadmin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
print("Seeded successfully.")
|
||||||
|
exit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Log directories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/log/salon_pos_admin /var/log/salon_pos_tenant
|
||||||
|
sudo chown salonpos:salonpos /var/log/salon_pos_admin /var/log/salon_pos_tenant
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. systemd services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/salon_pos_admin.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/salon_pos_tenant.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable salon_pos_admin salon_pos_tenant
|
||||||
|
sudo systemctl start salon_pos_admin salon_pos_tenant
|
||||||
|
sudo systemctl status salon_pos_admin salon_pos_tenant
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx.conf /etc/nginx/sites-available/salon_pos
|
||||||
|
sudo ln -s /etc/nginx/sites-available/salon_pos /etc/nginx/sites-enabled/
|
||||||
|
# Update IP allowlist in nginx.conf before enabling
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. Backup cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/backup/db_backup.sh /opt/salon_pos/deploy/backup/
|
||||||
|
sudo chmod +x /opt/salon_pos/deploy/backup/db_backup.sh
|
||||||
|
sudo mkdir -p /var/backups/salon_pos
|
||||||
|
sudo chown salonpos:salonpos /var/backups/salon_pos
|
||||||
|
# Install cron job for salonpos user
|
||||||
|
sudo crontab -u salonpos deploy/backup/backup.cron
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status
|
||||||
|
sudo systemctl status salon_pos_admin
|
||||||
|
sudo systemctl status salon_pos_tenant
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
sudo systemctl restart salon_pos_admin
|
||||||
|
sudo systemctl restart salon_pos_tenant
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
sudo journalctl -u salon_pos_admin -f
|
||||||
|
sudo journalctl -u salon_pos_tenant -f
|
||||||
|
tail -f /var/log/salon_pos_admin/error.log
|
||||||
|
tail -f /var/log/salon_pos_tenant/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migrations (Ongoing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/salon_pos
|
||||||
|
source venv/bin/activate
|
||||||
|
flask --app wsgi_tenant:app db migrate -m "Description of change"
|
||||||
|
flask --app wsgi_tenant:app db upgrade
|
||||||
|
|
||||||
|
# Rollback one step
|
||||||
|
flask --app wsgi_tenant:app db downgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/salon_pos
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install pytest
|
||||||
|
FLASK_ENV=testing pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monthly Restore Drill (Mandatory)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On staging server:
|
||||||
|
LATEST=$(ls -t /var/backups/salon_pos/*.sql.gz | head -1)
|
||||||
|
gunzip -c "$LATEST" | mysql -u root salon_pos_staging
|
||||||
|
echo "Restore drill completed: $LATEST"
|
||||||
|
# Document the result and date in this runbook.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Last restore drill:** _______________
|
||||||
|
**Performed by:** _______________
|
||||||
|
**Result:** _______________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd salon_pos
|
||||||
|
python3.11 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# Set FLASK_ENV=development in .env
|
||||||
|
flask --app wsgi_tenant:app db upgrade
|
||||||
|
flask --app wsgi_tenant:app run --port 5001
|
||||||
|
flask --app wsgi_admin:app run --port 5002
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
salon_pos/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── extensions.py # Shared Flask extensions
|
||||||
|
│ ├── context.py # load_tenant_context, load_location_context
|
||||||
|
│ ├── decorators.py # @require_role, @tenant_feature_required, @demo_readonly
|
||||||
|
│ ├── security.py # Security headers + input sanitisers
|
||||||
|
│ ├── forms.py # Password validation helper
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── platform.py # SystemUser, Tenant, Plan, AuditLog, ...
|
||||||
|
│ │ └── salon.py # All tenant-scoped models
|
||||||
|
│ ├── admin/ # admin.mydomain.com blueprints
|
||||||
|
│ └── tenant/ # mydomain.com blueprints
|
||||||
|
├── config.py # Dev / Prod / Test config classes
|
||||||
|
├── wsgi_admin.py # Gunicorn entrypoint — admin
|
||||||
|
├── wsgi_tenant.py # Gunicorn entrypoint — tenant
|
||||||
|
├── requirements.txt
|
||||||
|
├── .env.example
|
||||||
|
├── deploy/ # systemd units, Nginx config, backup scripts
|
||||||
|
└── tests/ # pytest test suites
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Shared package marker — do not instantiate apps here.
|
||||||
|
# Use create_admin_app() or create_tenant_app() instead.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
app/admin/__init__.py — Admin app factory (admin.mydomain.com).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from flask import Flask
|
||||||
|
from app.extensions import db, migrate, csrf, mail, scheduler, admin_login_manager, admin_jwt, limiter
|
||||||
|
from app.security import apply_security_headers, check_admin_ip
|
||||||
|
from config import get_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_app(config_override=None):
|
||||||
|
flask_app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder="../templates",
|
||||||
|
static_folder="../static",
|
||||||
|
static_url_path="/static/admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────
|
||||||
|
cfg = config_override or get_config()
|
||||||
|
flask_app.config.from_object(cfg)
|
||||||
|
|
||||||
|
# Admin portal uses a separate secret key and session cookie name
|
||||||
|
import os
|
||||||
|
flask_app.config["SECRET_KEY"] = os.environ.get(
|
||||||
|
"ADMIN_SECRET_KEY", flask_app.config["SECRET_KEY"]
|
||||||
|
)
|
||||||
|
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_admin_session"
|
||||||
|
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
||||||
|
seconds=int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600))
|
||||||
|
)
|
||||||
|
flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||||
|
flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
|
||||||
|
# ── Extensions ────────────────────────────────────────────
|
||||||
|
db.init_app(flask_app)
|
||||||
|
migrate.init_app(flask_app, db)
|
||||||
|
csrf.init_app(flask_app)
|
||||||
|
mail.init_app(flask_app)
|
||||||
|
limiter.init_app(flask_app)
|
||||||
|
|
||||||
|
# Admin login manager
|
||||||
|
admin_login_manager.login_view = "admin_auth.login"
|
||||||
|
admin_login_manager.login_message_category = "warning"
|
||||||
|
admin_login_manager.session_protection = "strong"
|
||||||
|
admin_login_manager.init_app(flask_app)
|
||||||
|
|
||||||
|
# Admin JWT
|
||||||
|
admin_jwt.init_app(flask_app)
|
||||||
|
|
||||||
|
# ── User loader ───────────────────────────────────────────
|
||||||
|
from app.models.platform import SystemUser
|
||||||
|
|
||||||
|
@admin_login_manager.user_loader
|
||||||
|
def load_admin_user(user_id: str):
|
||||||
|
if not user_id.startswith("system:"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
uid = int(user_id.split(":")[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return SystemUser.query.get(uid)
|
||||||
|
|
||||||
|
# ── Security ──────────────────────────────────────────────
|
||||||
|
apply_security_headers(flask_app)
|
||||||
|
check_admin_ip(flask_app)
|
||||||
|
|
||||||
|
# ── Blueprints ────────────────────────────────────────────
|
||||||
|
from app.admin.auth.routes import admin_auth_bp
|
||||||
|
flask_app.register_blueprint(admin_auth_bp)
|
||||||
|
|
||||||
|
# Placeholder blueprints registered in later phases:
|
||||||
|
# system_users, tenants, plans, billing, settings_override, audit_log, analytics
|
||||||
|
|
||||||
|
# ── Import all models for Migrate ─────────────────────────
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
logger.info("Admin app created (env=%s)", flask_app.config.get("FLASK_ENV"))
|
||||||
|
return flask_app
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/analytics/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/audit_log/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
app/admin/auth/routes.py — Admin portal authentication.
|
||||||
|
Routes: /admin/login, /admin/logout
|
||||||
|
Brute-force lockout: 5 failures → locked for LOGIN_LOCKOUT_MINUTES.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, session,
|
||||||
|
)
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from app.extensions import db, limiter
|
||||||
|
from app.models.platform import SystemUser, AuditLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
admin_auth_bp = Blueprint("admin_auth", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("admin_auth.dashboard_redirect"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
user = SystemUser.query.filter_by(email=email, is_active=True).first()
|
||||||
|
|
||||||
|
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
|
||||||
|
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
|
||||||
|
|
||||||
|
if user and user.is_locked():
|
||||||
|
logger.warning("Admin login blocked — account locked: %s", email)
|
||||||
|
error = f"Account locked. Try again in {lockout_minutes} minutes."
|
||||||
|
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
||||||
|
# Success — reset lockout counters
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
login_user(user, remember=False)
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
AuditLog.log(
|
||||||
|
actor_id=user.id,
|
||||||
|
actor_type="system_user",
|
||||||
|
action="auth.login",
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info("Admin login success: %s", email)
|
||||||
|
next_url = request.args.get("next") or url_for("admin_auth.dashboard_redirect")
|
||||||
|
return redirect(next_url)
|
||||||
|
else:
|
||||||
|
# Failed attempt
|
||||||
|
if user:
|
||||||
|
user.failed_login_attempts += 1
|
||||||
|
if user.failed_login_attempts >= max_attempts:
|
||||||
|
user.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=lockout_minutes
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Admin account locked after %d failures: %s",
|
||||||
|
max_attempts, email,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.warning("Admin login failed: %s", email)
|
||||||
|
error = "Invalid email or password."
|
||||||
|
|
||||||
|
return render_template("admin/auth/login.html", error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_auth_bp.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
AuditLog.log(
|
||||||
|
actor_id=current_user.id,
|
||||||
|
actor_type="system_user",
|
||||||
|
action="auth.logout",
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
logout_user()
|
||||||
|
session.clear()
|
||||||
|
logger.info("Admin logout")
|
||||||
|
return redirect(url_for("admin_auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_auth_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
def dashboard_redirect():
|
||||||
|
# Will route to the analytics dashboard in Phase 2
|
||||||
|
return redirect(url_for("admin_auth.login"))
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/billing/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
billing_bp = Blueprint("billing", __name__, url_prefix="/billing")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/plans/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
plans_bp = Blueprint("plans", __name__, url_prefix="/plans")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/settings_override/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/system_users/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/admin/tenants/routes.py
|
||||||
|
Phase 2 implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
context.py — Tenant and location context resolution.
|
||||||
|
load_tenant_context() and load_location_context() are registered
|
||||||
|
as before_request hooks in the tenant app factory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from flask import g, session, redirect, url_for, request, abort
|
||||||
|
from flask_login import current_user
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
from app.models.salon import Location, StaffLocation, Staff
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_tenant_context():
|
||||||
|
"""
|
||||||
|
Resolve g.tenant from the authenticated user's session.
|
||||||
|
Called on every request in the tenant portal.
|
||||||
|
Redirects to a locked page if the tenant is suspended or cancelled.
|
||||||
|
Skips public routes (checkin, booking, staff-login, auth).
|
||||||
|
"""
|
||||||
|
# Skip context resolution for public / auth endpoints
|
||||||
|
public_blueprints = {"tenant_auth", "staff_auth", "checkin", "booking", "static"}
|
||||||
|
if request.blueprint in public_blueprints:
|
||||||
|
g.tenant = None
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
g.tenant = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve tenant_id from the authenticated principal
|
||||||
|
if hasattr(current_user, "tenant_id"):
|
||||||
|
tenant_id = current_user.tenant_id
|
||||||
|
else:
|
||||||
|
g.tenant = None
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant = Tenant.query.get(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
logger.warning("Tenant %s not found for user %s", tenant_id, current_user)
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
g.tenant = tenant
|
||||||
|
|
||||||
|
# Enforce subscription status
|
||||||
|
if tenant.status == "suspended":
|
||||||
|
if request.endpoint != "tenant_auth.suspended":
|
||||||
|
return redirect(url_for("tenant_auth.suspended"))
|
||||||
|
|
||||||
|
if tenant.status == "cancelled":
|
||||||
|
if request.endpoint != "tenant_auth.cancelled":
|
||||||
|
return redirect(url_for("tenant_auth.cancelled"))
|
||||||
|
|
||||||
|
logger.debug("Tenant context loaded: %s", tenant.slug)
|
||||||
|
|
||||||
|
|
||||||
|
def load_location_context():
|
||||||
|
"""
|
||||||
|
Resolve g.location from session or default to the tenant's primary location.
|
||||||
|
For tenant_staff, validates that the staff member is assigned to the location.
|
||||||
|
Must be called after load_tenant_context().
|
||||||
|
"""
|
||||||
|
if not hasattr(g, "tenant") or g.tenant is None:
|
||||||
|
g.location = None
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
g.location = None
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant_id = g.tenant.id
|
||||||
|
|
||||||
|
# Try to load from session
|
||||||
|
location_id = session.get("active_location_id")
|
||||||
|
|
||||||
|
if location_id:
|
||||||
|
location = Location.query.filter_by(
|
||||||
|
id=location_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
is_active=True,
|
||||||
|
).filter(Location.deleted_at.is_(None)).first()
|
||||||
|
else:
|
||||||
|
location = None
|
||||||
|
|
||||||
|
# Fall back to primary location
|
||||||
|
if location is None:
|
||||||
|
location = Location.query.filter_by(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
is_primary=True,
|
||||||
|
is_active=True,
|
||||||
|
).filter(Location.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if location is None:
|
||||||
|
# Last resort: first active location
|
||||||
|
location = Location.query.filter_by(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
is_active=True,
|
||||||
|
).filter(Location.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if location:
|
||||||
|
session["active_location_id"] = location.id
|
||||||
|
|
||||||
|
# For tenant_staff: enforce location assignment
|
||||||
|
if location and hasattr(current_user, "get_id"):
|
||||||
|
user_id_str = current_user.get_id()
|
||||||
|
if user_id_str and user_id_str.startswith("staff:"):
|
||||||
|
staff_id = int(user_id_str.split(":")[1])
|
||||||
|
assigned = StaffLocation.query.filter_by(
|
||||||
|
staff_id=staff_id,
|
||||||
|
location_id=location.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
).first()
|
||||||
|
if not assigned:
|
||||||
|
logger.warning(
|
||||||
|
"Staff %s attempted access to unassigned location %s",
|
||||||
|
staff_id, location.id,
|
||||||
|
)
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
g.location = location
|
||||||
|
logger.debug(
|
||||||
|
"Location context loaded: %s",
|
||||||
|
location.name if location else "None",
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
decorators.py — Shared route decorators.
|
||||||
|
@require_role(*roles) — Enforce system role on a route.
|
||||||
|
@tenant_feature_required(flag) — Gate a route behind a plan feature flag.
|
||||||
|
@demo_readonly — Block write operations on the demo tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import functools
|
||||||
|
from flask import g, abort, jsonify, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles):
|
||||||
|
"""
|
||||||
|
Decorator that enforces the current user holds one of the specified roles.
|
||||||
|
Works for both system_users (superadmin) and tenant users / staff.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@require_role("superadmin")
|
||||||
|
"""
|
||||||
|
def decorator(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
user_role = getattr(current_user, "role", None)
|
||||||
|
if user_role not in roles:
|
||||||
|
logger.warning(
|
||||||
|
"Role check failed: user %s has role '%s', required one of %s",
|
||||||
|
getattr(current_user, "id", "?"), user_role, roles,
|
||||||
|
)
|
||||||
|
abort(403)
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_feature_required(flag: str):
|
||||||
|
"""
|
||||||
|
Decorator that gates a route behind a plan feature flag.
|
||||||
|
Checks g.tenant.plan.features_json for the given flag.
|
||||||
|
Also respects tenant_setting_overrides for force-enable/disable.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@tenant_feature_required("marketing")
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
|
"""
|
||||||
|
def decorator(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
tenant = getattr(g, "tenant", None)
|
||||||
|
if tenant is None:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
# Check for superadmin override (force-enable / force-disable)
|
||||||
|
from app.models.platform import TenantSettingOverride
|
||||||
|
override = TenantSettingOverride.query.filter_by(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
setting_key=f"feature.{flag}",
|
||||||
|
lifted_at=None,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if override is not None:
|
||||||
|
enabled = override.setting_value in ("1", "true", "True")
|
||||||
|
if not enabled:
|
||||||
|
logger.info(
|
||||||
|
"Feature '%s' force-disabled by admin override for tenant %s",
|
||||||
|
flag, tenant.slug,
|
||||||
|
)
|
||||||
|
abort(403)
|
||||||
|
# force-enabled: proceed regardless of plan
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
# Check plan feature flags
|
||||||
|
if tenant.plan is None or not tenant.plan.has_feature(flag):
|
||||||
|
logger.info(
|
||||||
|
"Feature '%s' not available on plan '%s' for tenant %s",
|
||||||
|
flag,
|
||||||
|
tenant.plan.name if tenant.plan else "unknown",
|
||||||
|
tenant.slug,
|
||||||
|
)
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def demo_readonly(fn):
|
||||||
|
"""
|
||||||
|
Decorator that blocks all write operations (POST, PUT, PATCH, DELETE)
|
||||||
|
when the active tenant is the demo tenant.
|
||||||
|
Returns 403 with a JSON or HTML response depending on the request type.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@demo_readonly
|
||||||
|
def create_customer():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
tenant = getattr(g, "tenant", None)
|
||||||
|
if tenant and tenant.is_demo:
|
||||||
|
logger.info(
|
||||||
|
"Demo write blocked: %s %s", request.method, request.path
|
||||||
|
)
|
||||||
|
if request.is_json or request.path.startswith("/api/"):
|
||||||
|
return jsonify(
|
||||||
|
error="Demo account is read-only. "
|
||||||
|
"Sign up for a full account to make changes."
|
||||||
|
), 403
|
||||||
|
from flask import flash, redirect, request as req
|
||||||
|
flash(
|
||||||
|
"This is a demo account. Sign up for a full account to make changes.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
return redirect(req.referrer or "/")
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
extensions.py — Shared Flask extension instances.
|
||||||
|
Imported by both app factories to avoid circular imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_mail import Mail
|
||||||
|
from flask_apscheduler import APScheduler
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
mail = Mail()
|
||||||
|
scheduler = APScheduler()
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
# Two separate LoginManager instances — one per app factory.
|
||||||
|
admin_login_manager = LoginManager()
|
||||||
|
tenant_login_manager = LoginManager()
|
||||||
|
|
||||||
|
# Two separate JWTManager instances — one per app factory.
|
||||||
|
admin_jwt = JWTManager()
|
||||||
|
tenant_jwt = JWTManager()
|
||||||
|
|
||||||
|
# Rate limiter — shared, keyed by remote address.
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
jwt = admin_jwt # shared alias used by both app factories
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
forms.py
|
||||||
|
Shared form utilities and password strength validator.
|
||||||
|
Per-module WTForms classes live in their respective blueprint directories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
_PASSWORD_MIN_LENGTH = 10
|
||||||
|
_HAS_UPPERCASE = re.compile(r"[A-Z]")
|
||||||
|
_HAS_LOWERCASE = re.compile(r"[a-z]")
|
||||||
|
_HAS_DIGIT = re.compile(r"\d")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str, confirm: str = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Validate password strength per the security policy:
|
||||||
|
- Minimum 10 characters
|
||||||
|
- Must include at least one uppercase letter
|
||||||
|
- Must include at least one lowercase letter
|
||||||
|
- Must include at least one digit
|
||||||
|
|
||||||
|
Returns an error string, or None if the password is acceptable.
|
||||||
|
If confirm is supplied, also checks they match.
|
||||||
|
"""
|
||||||
|
if not password:
|
||||||
|
return "Password is required."
|
||||||
|
if len(password) < _PASSWORD_MIN_LENGTH:
|
||||||
|
return f"Password must be at least {_PASSWORD_MIN_LENGTH} characters."
|
||||||
|
if not _HAS_UPPERCASE.search(password):
|
||||||
|
return "Password must include at least one uppercase letter."
|
||||||
|
if not _HAS_LOWERCASE.search(password):
|
||||||
|
return "Password must include at least one lowercase letter."
|
||||||
|
if not _HAS_DIGIT.search(password):
|
||||||
|
return "Password must include at least one digit."
|
||||||
|
if confirm is not None and password != confirm:
|
||||||
|
return "Passwords do not match."
|
||||||
|
return None
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
models/__init__.py
|
||||||
|
Import all models so Flask-Migrate can discover them.
|
||||||
|
JWTBlocklist lives in platform.py (platform-level table).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.platform import ( # noqa: F401
|
||||||
|
SystemUser,
|
||||||
|
Plan,
|
||||||
|
Tenant,
|
||||||
|
TenantBillingHistory,
|
||||||
|
TenantSettingOverride,
|
||||||
|
AuditLog,
|
||||||
|
JWTBlocklist,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.models.salon import ( # noqa: F401
|
||||||
|
TenantSetting,
|
||||||
|
Location,
|
||||||
|
LocationSetting,
|
||||||
|
User,
|
||||||
|
Customer,
|
||||||
|
Service,
|
||||||
|
Product,
|
||||||
|
Promotion,
|
||||||
|
Staff,
|
||||||
|
StaffLocation,
|
||||||
|
StaffSchedule,
|
||||||
|
Appointment,
|
||||||
|
Transaction,
|
||||||
|
TransactionItem,
|
||||||
|
Inventory,
|
||||||
|
InventoryLog,
|
||||||
|
CommissionLog,
|
||||||
|
StaffPayPeriod,
|
||||||
|
StaffClocking,
|
||||||
|
MarketingCampaign,
|
||||||
|
GiftCard,
|
||||||
|
CheckinQueue,
|
||||||
|
Waitlist,
|
||||||
|
CheckoutReview,
|
||||||
|
DailyReconciliation,
|
||||||
|
AppointmentReminder,
|
||||||
|
)
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
models/platform.py — Platform-level models (superadmin scope).
|
||||||
|
Tables: system_users, plans, tenants, tenant_billing_history,
|
||||||
|
tenant_setting_overrides, audit_log, jwt_blocklist
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemUser(db.Model):
|
||||||
|
__tablename__ = "system_users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
role = db.Column(db.String(50), nullable=False, default="superadmin")
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
|
password_reset_token = db.Column(db.String(255), nullable=True)
|
||||||
|
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# Flask-Login interface
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return f"system:{self.id}"
|
||||||
|
|
||||||
|
def is_locked(self):
|
||||||
|
if self.locked_until is None:
|
||||||
|
return False
|
||||||
|
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def record_login(self):
|
||||||
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
self.failed_login_attempts = 0
|
||||||
|
self.locked_until = None
|
||||||
|
self.last_login_at = _dt.now(_tz.utc)
|
||||||
|
|
||||||
|
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
|
||||||
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||||
|
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
|
||||||
|
if self.failed_login_attempts >= max_attempts:
|
||||||
|
self.locked_until = _dt.now(_tz.utc) + _td(minutes=lockout_minutes)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SystemUser {self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(db.Model):
|
||||||
|
__tablename__ = "plans"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
price_monthly = db.Column(db.Numeric(8, 2), nullable=False)
|
||||||
|
max_staff = db.Column(db.Integer, nullable=True) # None = unlimited
|
||||||
|
max_locations = db.Column(db.Integer, nullable=True) # None = unlimited
|
||||||
|
features_json = db.Column(db.JSON, nullable=False, default=dict)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
tenants = db.relationship("Tenant", back_populates="plan", lazy="dynamic")
|
||||||
|
|
||||||
|
def has_feature(self, flag: str) -> bool:
|
||||||
|
return bool(self.features_json.get(flag, False))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Plan {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(db.Model):
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
owner_email = db.Column(db.String(255), nullable=False)
|
||||||
|
plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=False)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="trial")
|
||||||
|
# status: 'active' | 'trial' | 'suspended' | 'cancelled'
|
||||||
|
trial_ends_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
subscription_expires_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
is_demo = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
plan = db.relationship("Plan", back_populates="tenants")
|
||||||
|
billing_history = db.relationship(
|
||||||
|
"TenantBillingHistory", back_populates="tenant", lazy="dynamic"
|
||||||
|
)
|
||||||
|
setting_overrides = db.relationship(
|
||||||
|
"TenantSettingOverride", back_populates="tenant", lazy="dynamic"
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_suspended(self):
|
||||||
|
return self.status in ("suspended", "cancelled")
|
||||||
|
|
||||||
|
def is_active_status(self):
|
||||||
|
return self.status in ("active", "trial")
|
||||||
|
|
||||||
|
def get_setting(self, key: str, default=None):
|
||||||
|
"""
|
||||||
|
Resolve a tenant setting, honouring superadmin overrides.
|
||||||
|
Override takes precedence if lifted_at is NULL.
|
||||||
|
Falls back to tenant_settings, then the supplied default.
|
||||||
|
"""
|
||||||
|
override = (
|
||||||
|
TenantSettingOverride.query
|
||||||
|
.filter_by(tenant_id=self.id, setting_key=key)
|
||||||
|
.filter(TenantSettingOverride.lifted_at.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if override:
|
||||||
|
return override.setting_value
|
||||||
|
|
||||||
|
from app.models.salon import TenantSetting
|
||||||
|
setting = TenantSetting.query.filter_by(
|
||||||
|
tenant_id=self.id, setting_key=key
|
||||||
|
).first()
|
||||||
|
return setting.setting_value if setting else default
|
||||||
|
|
||||||
|
def has_feature(self, flag: str) -> bool:
|
||||||
|
"""Check plan feature flag, allowing active overrides to force-enable/disable."""
|
||||||
|
override = (
|
||||||
|
TenantSettingOverride.query
|
||||||
|
.filter_by(tenant_id=self.id, setting_key=f"feature_{flag}")
|
||||||
|
.filter(TenantSettingOverride.lifted_at.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if override:
|
||||||
|
return override.setting_value.lower() in ("true", "1", "yes")
|
||||||
|
return self.plan.has_feature(flag) if self.plan else False
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Tenant {self.slug}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBillingHistory(db.Model):
|
||||||
|
__tablename__ = "tenant_billing_history"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
description = db.Column(db.String(255), nullable=False)
|
||||||
|
paid_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
invoice_ref = db.Column(db.String(100), nullable=True)
|
||||||
|
recorded_by = db.Column(db.Integer, db.ForeignKey("system_users.id"),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
tenant = db.relationship("Tenant", back_populates="billing_history")
|
||||||
|
recorder = db.relationship("SystemUser")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<BillingHistory tenant={self.tenant_id} amount={self.amount}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettingOverride(db.Model):
|
||||||
|
__tablename__ = "tenant_setting_overrides"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
setting_key = db.Column(db.String(100), nullable=False)
|
||||||
|
setting_value = db.Column(db.Text, nullable=True)
|
||||||
|
overridden_by = db.Column(db.Integer, db.ForeignKey("system_users.id"),
|
||||||
|
nullable=False)
|
||||||
|
overridden_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
lifted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
note = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
tenant = db.relationship("Tenant", back_populates="setting_overrides")
|
||||||
|
admin = db.relationship("SystemUser")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return self.lifted_at is None
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SettingOverride tenant={self.tenant_id} key={self.setting_key}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""
|
||||||
|
Immutable, append-only audit log.
|
||||||
|
Never issue UPDATE or DELETE against this table from application code.
|
||||||
|
Retention: records older than 365 days purged monthly via APScheduler.
|
||||||
|
"""
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
actor_id = db.Column(db.Integer, nullable=False)
|
||||||
|
actor_type = db.Column(db.String(50), nullable=False)
|
||||||
|
# actor_type: 'system_user' | 'tenant_user'
|
||||||
|
action = db.Column(db.String(100), nullable=False)
|
||||||
|
target_type = db.Column(db.String(100), nullable=True)
|
||||||
|
target_id = db.Column(db.Integer, nullable=True)
|
||||||
|
before_json = db.Column(db.JSON, nullable=True)
|
||||||
|
after_json = db.Column(db.JSON, nullable=True)
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
index=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def log(cls, actor_id, actor_type, action,
|
||||||
|
target_type=None, target_id=None,
|
||||||
|
before=None, after=None, ip_address=None):
|
||||||
|
"""
|
||||||
|
Helper to append an audit entry and flush to DB.
|
||||||
|
Usage:
|
||||||
|
AuditLog.log(
|
||||||
|
actor_id=current_user.id,
|
||||||
|
actor_type='system_user',
|
||||||
|
action='tenant.suspend',
|
||||||
|
target_type='tenant',
|
||||||
|
target_id=tenant.id,
|
||||||
|
before={'status': 'active'},
|
||||||
|
after={'status': 'suspended'},
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
entry = cls(
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_type=actor_type,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
before_json=before,
|
||||||
|
after_json=after,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
logger.info(
|
||||||
|
"AUDIT | actor=%s(%s) action=%s target=%s/%s",
|
||||||
|
actor_type, actor_id, action, target_type, target_id,
|
||||||
|
)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AuditLog {self.action} by {self.actor_type}:{self.actor_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class JWTBlocklist(db.Model):
|
||||||
|
"""
|
||||||
|
Stores revoked JWT refresh token JTIs.
|
||||||
|
Checked on every token refresh request.
|
||||||
|
"""
|
||||||
|
__tablename__ = "jwt_blocklist"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
jti = db.Column(db.String(36), nullable=False, unique=True, index=True)
|
||||||
|
token_type = db.Column(db.String(20), nullable=False, default="refresh")
|
||||||
|
revoked_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<JWTBlocklist jti={self.jti}>"
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
"""
|
||||||
|
models/salon.py — Tenant-level models (all carry tenant_id).
|
||||||
|
Soft delete convention: all tenant models include deleted_at.
|
||||||
|
Queries always filter WHERE deleted_at IS NULL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# User & Location
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
"""Tenant admin and manager accounts (email + password login)."""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
email = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.String(30), nullable=False)
|
||||||
|
# role: 'tenant_admin' | 'tenant_manager'
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
|
password_reset_token = db.Column(db.String(255), nullable=True)
|
||||||
|
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flask-Login interface
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return f"user:{self.id}"
|
||||||
|
|
||||||
|
def is_locked(self):
|
||||||
|
if self.locked_until is None:
|
||||||
|
return False
|
||||||
|
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def record_login(self):
|
||||||
|
self.failed_login_attempts = 0
|
||||||
|
self.locked_until = None
|
||||||
|
self.last_login_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
|
||||||
|
from datetime import timedelta
|
||||||
|
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
|
||||||
|
if self.failed_login_attempts >= max_attempts:
|
||||||
|
self.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User {self.email} tenant={self.tenant_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSetting(db.Model):
|
||||||
|
__tablename__ = "tenant_settings"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
setting_key = db.Column(db.String(100), nullable=False)
|
||||||
|
setting_value = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("tenant_id", "setting_key",
|
||||||
|
name="uq_tenant_settings_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<TenantSetting {self.setting_key}={self.setting_value}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Location(db.Model):
|
||||||
|
__tablename__ = "locations"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
address = db.Column(db.String(255), nullable=True)
|
||||||
|
phone = db.Column(db.String(30), nullable=True)
|
||||||
|
email = db.Column(db.String(255), nullable=True)
|
||||||
|
timezone = db.Column(db.String(60), nullable=False, default="America/New_York")
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
is_primary = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
settings = db.relationship("LocationSetting", back_populates="location",
|
||||||
|
lazy="dynamic")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Location {self.name} tenant={self.tenant_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class LocationSetting(db.Model):
|
||||||
|
__tablename__ = "location_settings"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
setting_key = db.Column(db.String(100), nullable=False)
|
||||||
|
setting_value = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
location = db.relationship("Location", back_populates="settings")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("location_id", "setting_key",
|
||||||
|
name="uq_location_settings_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Customers
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Customer(db.Model):
|
||||||
|
__tablename__ = "customers"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
phone = db.Column(db.String(30), nullable=True)
|
||||||
|
email = db.Column(db.String(255), nullable=True)
|
||||||
|
date_of_birth = db.Column(db.Date, nullable=True)
|
||||||
|
preferred_staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||||
|
nullable=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
loyalty_points = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
no_show_count = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
def soft_delete(self):
|
||||||
|
self.deleted_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Customer {self.name} tenant={self.tenant_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Services, Products & Promotions
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Service(db.Model):
|
||||||
|
__tablename__ = "services"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
category = db.Column(db.String(100), nullable=True)
|
||||||
|
duration_min = db.Column(db.Integer, nullable=False, default=30)
|
||||||
|
price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Service {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Product(db.Model):
|
||||||
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
sku = db.Column(db.String(100), nullable=True)
|
||||||
|
category = db.Column(db.String(100), nullable=True)
|
||||||
|
sale_price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Product {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Promotion(db.Model):
|
||||||
|
"""
|
||||||
|
applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all'
|
||||||
|
target_ids_json: list of IDs when applies_to is 'service' or 'product'; else null.
|
||||||
|
discount_percent: 1–100 integer.
|
||||||
|
Active window: starts_at <= NOW() <= ends_at AND is_active = true.
|
||||||
|
"""
|
||||||
|
__tablename__ = "promotions"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
discount_percent = db.Column(db.Integer, nullable=False)
|
||||||
|
applies_to = db.Column(db.String(30), nullable=False)
|
||||||
|
target_ids_json = db.Column(db.JSON, nullable=True)
|
||||||
|
starts_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
ends_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Promotion {self.name} {self.discount_percent}%>"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Staff
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Staff(db.Model):
|
||||||
|
"""
|
||||||
|
staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist'
|
||||||
|
pay_type: 'hourly' | 'salary' | 'guarantee'
|
||||||
|
pay_period: 'weekly' | 'biweekly' | 'monthly'
|
||||||
|
passcode_hash: bcrypt-hashed 4–6 digit PIN; set by tenant_admin at creation.
|
||||||
|
phone: unique within tenant; used for staff-login.
|
||||||
|
"""
|
||||||
|
__tablename__ = "staff"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
phone = db.Column(db.String(30), nullable=False)
|
||||||
|
passcode_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
staff_type = db.Column(db.String(30), nullable=False)
|
||||||
|
pay_type = db.Column(db.String(20), nullable=False, default="hourly")
|
||||||
|
hourly_rate = db.Column(db.Numeric(8, 2), nullable=True)
|
||||||
|
salary_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||||
|
guarantee_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||||
|
pay_period = db.Column(db.String(20), nullable=False, default="biweekly")
|
||||||
|
commission_rate = db.Column(db.Numeric(5, 2), nullable=True)
|
||||||
|
commission_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
passcode_failed_attempts = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
passcode_locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
locations = db.relationship("StaffLocation", back_populates="staff",
|
||||||
|
lazy="dynamic")
|
||||||
|
schedules = db.relationship("StaffSchedule", back_populates="staff",
|
||||||
|
lazy="dynamic")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("tenant_id", "phone", name="uq_staff_tenant_phone"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flask-Login interface (for staff portal session)
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return f"staff:{self.id}"
|
||||||
|
|
||||||
|
def is_passcode_locked(self):
|
||||||
|
if self.passcode_locked_until is None:
|
||||||
|
return False
|
||||||
|
return datetime.now(timezone.utc) < self.passcode_locked_until.replace(
|
||||||
|
tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_passcode_success(self):
|
||||||
|
self.passcode_failed_attempts = 0
|
||||||
|
self.passcode_locked_until = None
|
||||||
|
|
||||||
|
def record_passcode_failure(self, max_attempts: int, lockout_minutes: int):
|
||||||
|
from datetime import timedelta
|
||||||
|
self.passcode_failed_attempts = (self.passcode_failed_attempts or 0) + 1
|
||||||
|
if self.passcode_failed_attempts >= max_attempts:
|
||||||
|
self.passcode_locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
|
||||||
|
|
||||||
|
def soft_delete(self):
|
||||||
|
self.deleted_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def is_assigned_to_location(self, location_id: int) -> bool:
|
||||||
|
return any(loc.id == location_id for loc in self.locations)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Staff {self.name} tenant={self.tenant_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class StaffLocation(db.Model):
|
||||||
|
__tablename__ = "staff_locations"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
|
||||||
|
staff = db.relationship("Staff", back_populates="locations")
|
||||||
|
location = db.relationship("Location")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("staff_id", "location_id",
|
||||||
|
name="uq_staff_location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffSchedule(db.Model):
|
||||||
|
__tablename__ = "staff_schedules"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
day_of_week = db.Column(db.Integer, nullable=False) # 0=Mon … 6=Sun
|
||||||
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
|
end_time = db.Column(db.Time, nullable=False)
|
||||||
|
|
||||||
|
staff = db.relationship("Staff", back_populates="schedules")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Appointments
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Appointment(db.Model):
|
||||||
|
"""
|
||||||
|
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'
|
||||||
|
rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk' | null
|
||||||
|
"""
|
||||||
|
__tablename__ = "appointments"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||||
|
nullable=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||||
|
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
|
||||||
|
start_time = db.Column(db.DateTime, nullable=False)
|
||||||
|
end_time = db.Column(db.DateTime, nullable=True)
|
||||||
|
is_walk_in = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="pending")
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
cancellation_reason = db.Column(db.Text, nullable=True)
|
||||||
|
cancelled_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
rebook_source = db.Column(db.String(20), nullable=True)
|
||||||
|
rebooked_from_transaction_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("transactions.id"), nullable=True
|
||||||
|
)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
customer = db.relationship("Customer")
|
||||||
|
staff = db.relationship("Staff")
|
||||||
|
service = db.relationship("Service")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Appointment {self.id} status={self.status}>"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Transactions & POS
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Transaction(db.Model):
|
||||||
|
"""
|
||||||
|
payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other'
|
||||||
|
"""
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
|
||||||
|
nullable=True)
|
||||||
|
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||||
|
nullable=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||||
|
subtotal = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
discount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
tip_amount = db.Column(db.Numeric(8, 2), nullable=False, default=0)
|
||||||
|
gift_card_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
total = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
payment_method = db.Column(db.String(20), nullable=False)
|
||||||
|
payment_reference = db.Column(db.String(255), nullable=True)
|
||||||
|
gift_card_id = db.Column(db.Integer, db.ForeignKey("gift_cards.id"),
|
||||||
|
nullable=True)
|
||||||
|
review_request_sent_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
voided_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
voided_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
void_reason = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
items = db.relationship("TransactionItem", back_populates="transaction",
|
||||||
|
lazy="dynamic")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Transaction {self.id} total={self.total}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionItem(db.Model):
|
||||||
|
__tablename__ = "transaction_items"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
|
||||||
|
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=True)
|
||||||
|
qty = db.Column(db.Integer, nullable=False, default=1)
|
||||||
|
unit_price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||||
|
# unit_price: final price after promotion
|
||||||
|
original_price = db.Column(db.Numeric(8, 2), nullable=False)
|
||||||
|
# original_price: price before promotion
|
||||||
|
discount_percent = db.Column(db.Numeric(5, 2), nullable=False, default=0)
|
||||||
|
promotion_id = db.Column(db.Integer, db.ForeignKey("promotions.id"),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
transaction = db.relationship("Transaction", back_populates="items")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<TransactionItem tx={self.transaction_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Inventory
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Inventory(db.Model):
|
||||||
|
__tablename__ = "inventory"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
sku = db.Column(db.String(100), nullable=True)
|
||||||
|
category = db.Column(db.String(100), nullable=True)
|
||||||
|
qty_on_hand = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
reorder_level = db.Column(db.Integer, nullable=False, default=5)
|
||||||
|
cost_price = db.Column(db.Numeric(8, 2), nullable=True)
|
||||||
|
sale_price = db.Column(db.Numeric(8, 2), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Inventory {self.name} qty={self.qty_on_hand}>"
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryLog(db.Model):
|
||||||
|
__tablename__ = "inventory_log"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
inventory_id = db.Column(db.Integer, db.ForeignKey("inventory.id"),
|
||||||
|
nullable=False)
|
||||||
|
delta = db.Column(db.Integer, nullable=False)
|
||||||
|
reason = db.Column(db.String(255), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Commission & Pay
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CommissionLog(db.Model):
|
||||||
|
__tablename__ = "commission_log"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
|
||||||
|
nullable=False)
|
||||||
|
amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
period = db.Column(db.String(20), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffPayPeriod(db.Model):
|
||||||
|
"""
|
||||||
|
status: 'draft' | 'approved' | 'paid'
|
||||||
|
pay_type: mirrors staff.pay_type at the time of the period.
|
||||||
|
guarantee_topup: max(0, guarantee_amount - commission_amount) for 'guarantee' pay_type.
|
||||||
|
"""
|
||||||
|
__tablename__ = "staff_pay_periods"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
period_start = db.Column(db.Date, nullable=False)
|
||||||
|
period_end = db.Column(db.Date, nullable=False)
|
||||||
|
pay_type = db.Column(db.String(20), nullable=False)
|
||||||
|
base_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
commission_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
guarantee_topup = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="draft")
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffClocking(db.Model):
|
||||||
|
"""One row per shift. clocked_out_at NULL = currently clocked in."""
|
||||||
|
__tablename__ = "staff_clockings"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
clocked_in_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
clocked_out_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
total_minutes = db.Column(db.Integer, nullable=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Marketing
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MarketingCampaign(db.Model):
|
||||||
|
"""
|
||||||
|
channel: 'email' (SMS deferred)
|
||||||
|
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled'
|
||||||
|
"""
|
||||||
|
__tablename__ = "marketing_campaigns"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150), nullable=False)
|
||||||
|
channel = db.Column(db.String(20), nullable=False, default="email")
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="draft")
|
||||||
|
audience_filter_json = db.Column(db.JSON, nullable=True)
|
||||||
|
subject = db.Column(db.String(255), nullable=True)
|
||||||
|
message_body = db.Column(db.Text, nullable=True)
|
||||||
|
scheduled_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
sent_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
sent_count = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
open_count = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Gift Cards
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class GiftCard(db.Model):
|
||||||
|
__tablename__ = "gift_cards"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
code = db.Column(db.String(50), nullable=False)
|
||||||
|
original_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
remaining_balance = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
issued_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
issued_to_customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||||
|
nullable=True)
|
||||||
|
expires_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("tenant_id", "code", name="uq_gift_card_tenant_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Check-in Kiosk & Waitlist
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CheckinQueue(db.Model):
|
||||||
|
"""
|
||||||
|
status: 'waiting' | 'acknowledged' | 'seated' | 'expired'
|
||||||
|
Surfaced to receptionist dashboard via 5-second polling.
|
||||||
|
"""
|
||||||
|
__tablename__ = "checkin_queue"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||||
|
nullable=True)
|
||||||
|
customer_name = db.Column(db.String(150), nullable=False)
|
||||||
|
customer_phone = db.Column(db.String(30), nullable=False)
|
||||||
|
service_requested = db.Column(db.String(150), nullable=True)
|
||||||
|
checked_in_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="waiting")
|
||||||
|
acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"),
|
||||||
|
nullable=True)
|
||||||
|
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Waitlist(db.Model):
|
||||||
|
"""status: 'waiting' | 'notified' | 'booked' | 'expired'"""
|
||||||
|
__tablename__ = "waitlist"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
customer_name = db.Column(db.String(150), nullable=False)
|
||||||
|
customer_phone = db.Column(db.String(30), nullable=True)
|
||||||
|
customer_email = db.Column(db.String(255), nullable=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||||
|
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
|
||||||
|
requested_date = db.Column(db.Date, nullable=True)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="waiting")
|
||||||
|
notified_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Reviews & Reconciliation
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CheckoutReview(db.Model):
|
||||||
|
"""
|
||||||
|
rating: 1–5 integer.
|
||||||
|
is_public_suggested: True if rating >= 4 (platform links shown).
|
||||||
|
"""
|
||||||
|
__tablename__ = "checkout_reviews"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
|
||||||
|
nullable=False)
|
||||||
|
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
|
||||||
|
nullable=True)
|
||||||
|
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
|
||||||
|
rating = db.Column(db.Integer, nullable=False)
|
||||||
|
comment = db.Column(db.Text, nullable=True)
|
||||||
|
is_public_suggested = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
google_clicked = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
facebook_clicked = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
yelp_clicked = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReconciliation(db.Model):
|
||||||
|
"""variance = actual_cash_counted - expected_cash_in_drawer (negative = shortage)"""
|
||||||
|
__tablename__ = "daily_reconciliations"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
date = db.Column(db.Date, nullable=False)
|
||||||
|
total_cash = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
total_app_payments = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
total_tips = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
total_gift_card_redemptions = db.Column(db.Numeric(10, 2), nullable=False,
|
||||||
|
default=0)
|
||||||
|
expected_cash_in_drawer = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||||
|
actual_cash_counted = db.Column(db.Numeric(10, 2), nullable=True)
|
||||||
|
variance = db.Column(db.Numeric(10, 2), nullable=True)
|
||||||
|
closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
closed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("tenant_id", "location_id", "date",
|
||||||
|
name="uq_reconciliation_location_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppointmentReminder(db.Model):
|
||||||
|
"""
|
||||||
|
reminder_type: '24h' | '2h'
|
||||||
|
channel: 'email' (SMS deferred)
|
||||||
|
status: 'pending' | 'sent' | 'failed' | 'cancelled'
|
||||||
|
"""
|
||||||
|
__tablename__ = "appointment_reminders"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
reminder_type = db.Column(db.String(10), nullable=False)
|
||||||
|
scheduled_for = db.Column(db.DateTime, nullable=False)
|
||||||
|
sent_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
channel = db.Column(db.String(20), nullable=False, default="email")
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="pending")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
security.py — Security middleware and input sanitisation helpers.
|
||||||
|
- apply_security_headers(app) — Attach security headers to every response.
|
||||||
|
- check_admin_ip(app) — Enforce ADMIN_IP_ALLOWLIST at the Flask layer.
|
||||||
|
- sanitise_string(value) — Strip control characters from user input.
|
||||||
|
- validate_slug(slug) — Validate tenant slug format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import ipaddress
|
||||||
|
from flask import request, abort, current_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Regex: slugs must be lowercase alphanumeric + hyphens, 2–80 chars
|
||||||
|
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{1,78}[a-z0-9]$")
|
||||||
|
|
||||||
|
# Strip ASCII control characters (0x00–0x1F, 0x7F) and Unicode C0/C1 blocks
|
||||||
|
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f\x80-\x9f]")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_security_headers(app):
|
||||||
|
"""
|
||||||
|
Register an after_request hook that attaches security headers to every
|
||||||
|
response. Nginx adds HSTS and the more restrictive CSP for production;
|
||||||
|
this layer provides defence-in-depth and covers the development server.
|
||||||
|
"""
|
||||||
|
@app.after_request
|
||||||
|
def _add_headers(response):
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
# Basic CSP — tightened further in Nginx for production
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"frame-ancestors 'none';"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def check_admin_ip(app):
|
||||||
|
"""
|
||||||
|
Register a before_request hook on the admin app that validates the
|
||||||
|
client IP against ADMIN_IP_ALLOWLIST. Provides a Flask-layer double-check
|
||||||
|
behind Nginx's allow/deny directives.
|
||||||
|
"""
|
||||||
|
@app.before_request
|
||||||
|
def _check_ip():
|
||||||
|
allowlist_raw = app.config.get("ADMIN_IP_ALLOWLIST", "")
|
||||||
|
if not allowlist_raw.strip():
|
||||||
|
return # No allowlist configured — skip check (dev mode)
|
||||||
|
|
||||||
|
networks = []
|
||||||
|
for cidr in allowlist_raw.split(","):
|
||||||
|
cidr = cidr.strip()
|
||||||
|
if cidr:
|
||||||
|
try:
|
||||||
|
networks.append(ipaddress.ip_network(cidr, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Invalid CIDR in ADMIN_IP_ALLOWLIST: %s", cidr)
|
||||||
|
|
||||||
|
if not networks:
|
||||||
|
return
|
||||||
|
|
||||||
|
client_ip_str = request.headers.get("X-Real-IP") or request.remote_addr
|
||||||
|
try:
|
||||||
|
client_ip = ipaddress.ip_address(client_ip_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Unparseable client IP: %s", client_ip_str)
|
||||||
|
abort(403)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not any(client_ip in net for net in networks):
|
||||||
|
logger.warning(
|
||||||
|
"Admin IP blocked: %s not in allowlist", client_ip_str
|
||||||
|
)
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitise_string(value: str, max_length: int = None) -> str:
|
||||||
|
"""
|
||||||
|
Strip control characters from a user-supplied string.
|
||||||
|
Optionally truncate to max_length.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
cleaned = _CONTROL_CHAR_RE.sub("", value).strip()
|
||||||
|
if max_length:
|
||||||
|
cleaned = cleaned[:max_length]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def validate_slug(slug: str) -> bool:
|
||||||
|
"""Return True if the slug matches the allowed pattern."""
|
||||||
|
return bool(_SLUG_RE.match(slug))
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* Admin portal custom styles — extended in later phases */
|
||||||
|
body { background-color: #f4f6f9; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* Tenant portal custom styles — extended in later phases */
|
||||||
|
body { background-color: #f8f9fa; }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}Admin Login{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h4 class="card-title mb-4 text-center">Admin Portal</h4>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('admin_auth.login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" name="email" class="form-control" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-dark w-100">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Admin Portal{% endblock %} — Nails Salon POS</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='admin/css/main.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="navbar navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand">Salon POS Admin</span>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="text-light small">{{ current_user.name }}</span>
|
||||||
|
<a href="{{ url_for('admin_auth.logout') }}" class="btn btn-outline-light btn-sm">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<main class="container py-4">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Account Cancelled{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-5">
|
||||||
|
<div class="col-md-5 text-center">
|
||||||
|
<h3 class="text-danger">Account Cancelled</h3>
|
||||||
|
<p class="text-muted">This account has been cancelled. Please contact support if you believe this is an error.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Sign In{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h4 class="card-title mb-4 text-center">Salon Login</h4>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('tenant_auth.login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" name="email" class="form-control" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div class="text-center small">
|
||||||
|
<a href="{{ url_for('tenant_auth.password_reset_request') }}">Forgot password?</a>
|
||||||
|
|
|
||||||
|
<a href="{{ url_for('staff_auth.staff_login') }}">Staff login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Set New Password{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title mb-3">Set New Password</h5>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">New Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" required minlength="10">
|
||||||
|
<div class="form-text">Min 10 chars, must include uppercase, lowercase, and a digit.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" name="confirm_password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Update Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Reset Password{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title mb-3">Reset Password</h5>
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-info">{{ message }}</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email address</label>
|
||||||
|
<input type="email" name="email" class="form-control" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-center mt-3 small">
|
||||||
|
<a href="{{ url_for('tenant_auth.login') }}">Back to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Account Suspended{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-5">
|
||||||
|
<div class="col-md-5 text-center">
|
||||||
|
<h3 class="text-warning">Account Suspended</h3>
|
||||||
|
<p class="text-muted">Your salon account has been suspended. Please contact support to resolve your billing.</p>
|
||||||
|
<a href="{{ url_for('tenant_auth.login') }}" class="btn btn-outline-secondary mt-2">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Salon Portal{% endblock %} — Nails Salon POS</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='tenant/css/main.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="navbar navbar-light bg-white border-bottom shadow-sm">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand fw-bold">
|
||||||
|
{% if g.tenant %}{{ g.tenant.name }}{% else %}Salon POS{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if g.location %}
|
||||||
|
<span class="badge bg-secondary">{{ g.location.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary btn-sm">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Welcome to {{ tenant.name }}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #f8f9fa; }
|
||||||
|
.kiosk-card { max-width: 540px; margin: 60px auto; }
|
||||||
|
.kiosk-title { font-size: 2rem; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kiosk-card">
|
||||||
|
{% if confirmed %}
|
||||||
|
<div class="card shadow text-center p-5" id="confirmation">
|
||||||
|
<div class="display-1 mb-3">✅</div>
|
||||||
|
<h2 class="kiosk-title">You're checked in!</h2>
|
||||||
|
<p class="text-muted mt-2">A staff member will be right with you.</p>
|
||||||
|
<p class="text-muted small" id="reset-countdown">Resetting in <span id="countdown">10</span>s…</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let n = 10;
|
||||||
|
const el = document.getElementById("countdown");
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
n--;
|
||||||
|
el.textContent = n;
|
||||||
|
if (n <= 0) { clearInterval(timer); window.location.reload(); }
|
||||||
|
}, 1000);
|
||||||
|
</script>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow p-4">
|
||||||
|
<h2 class="kiosk-title text-center mb-4">Welcome to {{ tenant.name }}</h2>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fs-5">Your Name</label>
|
||||||
|
<input type="text" name="customer_name" class="form-control form-control-lg"
|
||||||
|
placeholder="First and last name" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fs-5">Phone Number</label>
|
||||||
|
<input type="tel" name="customer_phone" class="form-control form-control-lg"
|
||||||
|
placeholder="e.g. 5551234567" inputmode="numeric" required>
|
||||||
|
</div>
|
||||||
|
{% if services %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fs-5">Service (optional)</label>
|
||||||
|
<select name="service_requested" class="form-select form-select-lg">
|
||||||
|
<option value="">— Select a service —</option>
|
||||||
|
{% for svc in services %}
|
||||||
|
<option value="{{ svc.name }}">{{ svc.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-100 mt-2">Check In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="mb-4">Dashboard
|
||||||
|
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %}
|
||||||
|
</h4>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts).
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "tenant/base.html" %}
|
||||||
|
{% block title %}Staff Login{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm mt-5">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h4 class="card-title mb-4 text-center">Staff Login</h4>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('staff_auth.staff_login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Phone Number</label>
|
||||||
|
<input type="tel" name="phone" class="form-control form-control-lg"
|
||||||
|
placeholder="e.g. 5551234567" required autofocus inputmode="numeric">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Passcode</label>
|
||||||
|
<input type="password" name="passcode" class="form-control form-control-lg"
|
||||||
|
placeholder="4–6 digit PIN" maxlength="6" inputmode="numeric" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success w-100 btn-lg">Clock In</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-center mt-3 small">
|
||||||
|
<a href="{{ url_for('tenant_auth.login') }}">Manager / Owner login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/__init__.py — Tenant app factory (mydomain.com).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
from app.extensions import (
|
||||||
|
db, migrate, csrf, mail, scheduler,
|
||||||
|
tenant_login_manager, tenant_jwt, limiter,
|
||||||
|
)
|
||||||
|
from app.security import apply_security_headers
|
||||||
|
from app.context import load_tenant_context, load_location_context
|
||||||
|
from config import get_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tenant_app(config_override=None):
|
||||||
|
flask_app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder="../templates",
|
||||||
|
static_folder="../static",
|
||||||
|
static_url_path="/static/tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────
|
||||||
|
cfg = config_override or get_config()
|
||||||
|
flask_app.config.from_object(cfg)
|
||||||
|
|
||||||
|
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_tenant_session"
|
||||||
|
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
||||||
|
seconds=int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800))
|
||||||
|
)
|
||||||
|
flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||||
|
flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
|
||||||
|
# ── Extensions ────────────────────────────────────────────
|
||||||
|
db.init_app(flask_app)
|
||||||
|
migrate.init_app(flask_app, db)
|
||||||
|
csrf.init_app(flask_app)
|
||||||
|
mail.init_app(flask_app)
|
||||||
|
limiter.init_app(flask_app)
|
||||||
|
|
||||||
|
# Tenant login manager
|
||||||
|
tenant_login_manager.login_view = "tenant_auth.login"
|
||||||
|
tenant_login_manager.login_message_category = "warning"
|
||||||
|
tenant_login_manager.session_protection = "strong"
|
||||||
|
tenant_login_manager.init_app(flask_app)
|
||||||
|
|
||||||
|
# Tenant JWT
|
||||||
|
tenant_jwt.init_app(flask_app)
|
||||||
|
|
||||||
|
# ── User loaders ──────────────────────────────────────────
|
||||||
|
from app.models.salon import User, Staff
|
||||||
|
|
||||||
|
@tenant_login_manager.user_loader
|
||||||
|
def load_tenant_user(user_id: str):
|
||||||
|
if user_id.startswith("user:"):
|
||||||
|
try:
|
||||||
|
uid = int(user_id.split(":")[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return User.query.filter_by(id=uid, is_active=True)\
|
||||||
|
.filter(User.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if user_id.startswith("staff:"):
|
||||||
|
try:
|
||||||
|
sid = int(user_id.split(":")[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return Staff.query.filter_by(id=sid, is_active=True)\
|
||||||
|
.filter(Staff.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Context hooks ─────────────────────────────────────────
|
||||||
|
flask_app.before_request(load_tenant_context)
|
||||||
|
flask_app.before_request(load_location_context)
|
||||||
|
|
||||||
|
# ── Security ──────────────────────────────────────────────
|
||||||
|
apply_security_headers(flask_app)
|
||||||
|
|
||||||
|
# ── Blueprints ────────────────────────────────────────────
|
||||||
|
from app.tenant.auth.routes import tenant_auth_bp
|
||||||
|
from app.tenant.staff_auth.routes import staff_auth_bp
|
||||||
|
from app.tenant.checkin.routes import checkin_bp
|
||||||
|
from app.tenant.dashboard.routes import dashboard_bp
|
||||||
|
|
||||||
|
flask_app.register_blueprint(tenant_auth_bp)
|
||||||
|
flask_app.register_blueprint(staff_auth_bp)
|
||||||
|
flask_app.register_blueprint(checkin_bp)
|
||||||
|
flask_app.register_blueprint(dashboard_bp)
|
||||||
|
|
||||||
|
# Remaining blueprints registered in Phases 3–6:
|
||||||
|
# locations, customers, appointments, services, pos, staff,
|
||||||
|
# staff_portal, booking, waitlist, gift_cards, reviews,
|
||||||
|
# reconciliation, inventory, marketing, reports, settings
|
||||||
|
|
||||||
|
# ── Import all models for Migrate ─────────────────────────
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
logger.info("Tenant app created (env=%s)", flask_app.config.get("FLASK_ENV"))
|
||||||
|
return flask_app
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/appointments/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/auth/routes.py — Tenant portal authentication (email + password).
|
||||||
|
Routes: /login, /logout, /password-reset, /suspended, /cancelled
|
||||||
|
Roles: tenant_admin, tenant_manager
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, session, g,
|
||||||
|
)
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from app.extensions import db, limiter, mail
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
from app.models.salon import User
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
tenant_auth_bp = Blueprint("tenant_auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@tenant_auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
user = User.query.filter_by(email=email, is_active=True)\
|
||||||
|
.filter(User.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
|
||||||
|
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
|
||||||
|
|
||||||
|
if user and user.is_locked():
|
||||||
|
logger.warning("Tenant login blocked — account locked: %s", email)
|
||||||
|
error = f"Account locked. Try again in {lockout_minutes} minutes."
|
||||||
|
|
||||||
|
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
||||||
|
# Verify tenant is accessible
|
||||||
|
tenant = Tenant.query.get(user.tenant_id)
|
||||||
|
if not tenant or tenant.status == "cancelled":
|
||||||
|
error = "This account is no longer active."
|
||||||
|
else:
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
login_user(user, remember=False)
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Tenant login success: user=%s tenant=%s",
|
||||||
|
user.id, user.tenant_id,
|
||||||
|
)
|
||||||
|
return redirect(url_for("dashboard.index"))
|
||||||
|
else:
|
||||||
|
if user:
|
||||||
|
user.failed_login_attempts += 1
|
||||||
|
if user.failed_login_attempts >= max_attempts:
|
||||||
|
user.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=lockout_minutes
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Tenant account locked after %d failures: %s",
|
||||||
|
max_attempts, email,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.warning("Tenant login failed: %s", email)
|
||||||
|
error = "Invalid email or password."
|
||||||
|
|
||||||
|
return render_template("tenant/auth/login.html", error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@tenant_auth_bp.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
session.clear()
|
||||||
|
logger.info("Tenant logout")
|
||||||
|
return redirect(url_for("tenant_auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@tenant_auth_bp.route("/password-reset", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("5 per hour")
|
||||||
|
def password_reset_request():
|
||||||
|
"""Step 1: submit email → send reset link."""
|
||||||
|
message = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
user = User.query.filter_by(email=email)\
|
||||||
|
.filter(User.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
user.password_reset_token = token
|
||||||
|
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
reset_url = url_for(
|
||||||
|
"tenant_auth.password_reset_confirm",
|
||||||
|
token=token,
|
||||||
|
_external=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
msg = Message(
|
||||||
|
subject="Reset your password",
|
||||||
|
recipients=[email],
|
||||||
|
body=f"Click the link below to reset your password (valid 1 hour):\n\n{reset_url}",
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
logger.info("Password reset email sent to %s", email)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to send reset email to %s: %s", email, exc)
|
||||||
|
|
||||||
|
# Always show the same message to prevent email enumeration
|
||||||
|
message = "If that email is registered, a reset link has been sent."
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tenant/auth/password_reset_request.html", message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tenant_auth_bp.route("/password-reset/<token>", methods=["GET", "POST"])
|
||||||
|
def password_reset_confirm(token):
|
||||||
|
"""Step 2: confirm token → set new password."""
|
||||||
|
user = User.query.filter_by(password_reset_token=token)\
|
||||||
|
.filter(User.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if not user or not user.password_reset_expires_at:
|
||||||
|
flash("Invalid or expired reset link.", "danger")
|
||||||
|
return redirect(url_for("tenant_auth.login"))
|
||||||
|
|
||||||
|
expires = user.password_reset_expires_at
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if now > expires:
|
||||||
|
flash("This reset link has expired. Please request a new one.", "danger")
|
||||||
|
return redirect(url_for("tenant_auth.password_reset_request"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
new_password = request.form.get("password", "")
|
||||||
|
confirm = request.form.get("confirm_password", "")
|
||||||
|
|
||||||
|
if new_password != confirm:
|
||||||
|
error = "Passwords do not match."
|
||||||
|
elif len(new_password) < 10:
|
||||||
|
error = "Password must be at least 10 characters."
|
||||||
|
elif not any(c.isupper() for c in new_password):
|
||||||
|
error = "Password must contain at least one uppercase letter."
|
||||||
|
elif not any(c.islower() for c in new_password):
|
||||||
|
error = "Password must contain at least one lowercase letter."
|
||||||
|
elif not any(c.isdigit() for c in new_password):
|
||||||
|
error = "Password must contain at least one digit."
|
||||||
|
else:
|
||||||
|
hashed = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12))
|
||||||
|
user.password_hash = hashed.decode()
|
||||||
|
user.password_reset_token = None
|
||||||
|
user.password_reset_expires_at = None
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info("Password reset completed for user %s", user.id)
|
||||||
|
flash("Password updated. Please log in.", "success")
|
||||||
|
return redirect(url_for("tenant_auth.login"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tenant/auth/password_reset_confirm.html", error=error, token=token
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tenant_auth_bp.route("/suspended")
|
||||||
|
def suspended():
|
||||||
|
return render_template("tenant/auth/suspended.html"), 403
|
||||||
|
|
||||||
|
|
||||||
|
@tenant_auth_bp.route("/cancelled")
|
||||||
|
def cancelled():
|
||||||
|
return render_template("tenant/auth/cancelled.html"), 403
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/booking/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
booking_bp = Blueprint("booking", __name__, url_prefix="")
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/checkin/routes.py — Customer self check-in kiosk.
|
||||||
|
Route: GET/POST /checkin/<tenant_slug>
|
||||||
|
No authentication required. CSRF-exempt. Rate-limited.
|
||||||
|
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, request, jsonify,
|
||||||
|
current_app, abort,
|
||||||
|
)
|
||||||
|
from app.extensions import db, limiter, csrf
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
from app.models.salon import Customer, CheckinQueue, Location
|
||||||
|
from app.security import sanitise_string, validate_slug
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
checkin_bp = Blueprint("checkin", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
|
||||||
|
@csrf.exempt
|
||||||
|
@limiter.limit("20 per minute")
|
||||||
|
def kiosk(tenant_slug: str):
|
||||||
|
"""
|
||||||
|
Public kiosk page. No auth required.
|
||||||
|
Tenant slug validated against known slugs (not guessable).
|
||||||
|
Accepts: name, phone, service_requested — all other fields ignored.
|
||||||
|
Phone is sanitised and validated before profile lookup.
|
||||||
|
Page auto-resets after 10 seconds (configurable per tenant) via JS.
|
||||||
|
"""
|
||||||
|
# Validate slug format before hitting the DB
|
||||||
|
if not validate_slug(tenant_slug):
|
||||||
|
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first()
|
||||||
|
if not tenant or not tenant.is_active_status():
|
||||||
|
logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Resolve the primary location for this tenant
|
||||||
|
location = Location.query.filter_by(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
is_primary=True,
|
||||||
|
is_active=True,
|
||||||
|
).filter(Location.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if location is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
confirmed = False
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
raw_name = request.form.get("customer_name", "")
|
||||||
|
raw_phone = request.form.get("customer_phone", "")
|
||||||
|
raw_service = request.form.get("service_requested", "")
|
||||||
|
|
||||||
|
name = sanitise_string(raw_name, max_length=150)
|
||||||
|
phone = sanitise_string(raw_phone, max_length=30)
|
||||||
|
service = sanitise_string(raw_service, max_length=150)
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not name or not phone:
|
||||||
|
return render_template(
|
||||||
|
"tenant/checkin/kiosk.html",
|
||||||
|
tenant=tenant,
|
||||||
|
error="Name and phone number are required.",
|
||||||
|
confirmed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strip non-digit characters from phone for lookup consistency
|
||||||
|
phone_digits = "".join(filter(str.isdigit, phone))
|
||||||
|
if len(phone_digits) < 7:
|
||||||
|
return render_template(
|
||||||
|
"tenant/checkin/kiosk.html",
|
||||||
|
tenant=tenant,
|
||||||
|
error="Please enter a valid phone number.",
|
||||||
|
confirmed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up or create customer profile
|
||||||
|
customer = Customer.query.filter_by(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
phone=phone_digits,
|
||||||
|
).filter(Customer.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if customer is None:
|
||||||
|
customer = Customer(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
name=name,
|
||||||
|
phone=phone_digits,
|
||||||
|
)
|
||||||
|
db.session.add(customer)
|
||||||
|
db.session.flush() # get customer.id before committing
|
||||||
|
logger.info(
|
||||||
|
"Kiosk: new customer profile created for tenant %s", tenant.slug
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue the walk-in entry
|
||||||
|
entry = CheckinQueue(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
location_id=location.id,
|
||||||
|
customer_id=customer.id,
|
||||||
|
customer_name=name,
|
||||||
|
customer_phone=phone_digits,
|
||||||
|
service_requested=service or None,
|
||||||
|
status="waiting",
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Kiosk: check-in queued for tenant=%s location=%s customer=%s",
|
||||||
|
tenant.slug, location.id, customer.id,
|
||||||
|
)
|
||||||
|
confirmed = True
|
||||||
|
|
||||||
|
# Fetch services for the kiosk selector (if configured)
|
||||||
|
from app.models.salon import Service
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
is_active=True,
|
||||||
|
).filter(Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tenant/checkin/kiosk.html",
|
||||||
|
tenant=tenant,
|
||||||
|
services=services,
|
||||||
|
confirmed=confirmed,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/customers/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/dashboard/routes.py — Tenant dashboard (placeholder for Phase 3 KPIs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from flask import Blueprint, render_template, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.decorators import require_role
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
dashboard_bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
return render_template(
|
||||||
|
"tenant/dashboard/index.html",
|
||||||
|
tenant=g.tenant,
|
||||||
|
location=g.location,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/gift_cards/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/inventory/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/locations/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/marketing/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/pos/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/reconciliation/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/reports/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/reviews/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/services/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
services_bp = Blueprint("services", __name__, url_prefix="/services")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/settings/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/staff/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/staff_auth/routes.py — Staff portal authentication (phone + passcode).
|
||||||
|
Routes: /staff-login, /staff-logout
|
||||||
|
Brute-force: 5 failures → passcode_locked_until for LOGIN_LOCKOUT_MINUTES.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for,
|
||||||
|
request, current_app, session, flash,
|
||||||
|
)
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from app.extensions import db, limiter
|
||||||
|
from app.models.salon import Staff
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
staff_auth_bp = Blueprint("staff_auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_auth_bp.route("/staff-login", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def staff_login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("staff_portal.index"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
phone = request.form.get("phone", "").strip()
|
||||||
|
passcode = request.form.get("passcode", "").strip()
|
||||||
|
|
||||||
|
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
|
||||||
|
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
|
||||||
|
min_len = current_app.config.get("STAFF_PASSCODE_MIN_LENGTH", 4)
|
||||||
|
max_len = current_app.config.get("STAFF_PASSCODE_MAX_LENGTH", 6)
|
||||||
|
|
||||||
|
# Basic input validation
|
||||||
|
if not phone or not passcode:
|
||||||
|
error = "Phone number and passcode are required."
|
||||||
|
elif not passcode.isdigit() or not (min_len <= len(passcode) <= max_len):
|
||||||
|
error = f"Passcode must be {min_len}–{max_len} digits."
|
||||||
|
else:
|
||||||
|
# Look up staff by phone (phone is unique within tenant)
|
||||||
|
# Platform-wide: find first matching phone, then validate tenant
|
||||||
|
staff = Staff.query.filter_by(phone=phone, is_active=True)\
|
||||||
|
.filter(Staff.deleted_at.is_(None)).first()
|
||||||
|
|
||||||
|
if staff and staff.is_passcode_locked():
|
||||||
|
logger.warning(
|
||||||
|
"Staff passcode login blocked — locked: staff=%s", staff.id
|
||||||
|
)
|
||||||
|
error = f"Account locked. Try again in {lockout_minutes} minutes."
|
||||||
|
|
||||||
|
elif staff and bcrypt.checkpw(
|
||||||
|
passcode.encode(), staff.passcode_hash.encode()
|
||||||
|
):
|
||||||
|
# Verify the tenant is active
|
||||||
|
tenant = Tenant.query.get(staff.tenant_id)
|
||||||
|
if not tenant or not tenant.is_active_status():
|
||||||
|
error = "Your salon account is not active."
|
||||||
|
else:
|
||||||
|
# Success
|
||||||
|
staff.passcode_failed_attempts = 0
|
||||||
|
staff.passcode_locked_until = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
login_user(staff, remember=False)
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# Set active location to staff's primary assigned location
|
||||||
|
from app.models.salon import StaffLocation
|
||||||
|
assignment = StaffLocation.query.filter_by(
|
||||||
|
staff_id=staff.id,
|
||||||
|
tenant_id=staff.tenant_id,
|
||||||
|
).first()
|
||||||
|
if assignment:
|
||||||
|
session["active_location_id"] = assignment.location_id
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Staff login success: staff=%s tenant=%s",
|
||||||
|
staff.id, staff.tenant_id,
|
||||||
|
)
|
||||||
|
return redirect(url_for("staff_portal.index"))
|
||||||
|
else:
|
||||||
|
if staff:
|
||||||
|
staff.passcode_failed_attempts += 1
|
||||||
|
if staff.passcode_failed_attempts >= max_attempts:
|
||||||
|
staff.passcode_locked_until = datetime.now(timezone.utc) + \
|
||||||
|
timedelta(minutes=lockout_minutes)
|
||||||
|
logger.warning(
|
||||||
|
"Staff passcode locked after %d failures: staff=%s",
|
||||||
|
max_attempts, staff.id,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.warning("Staff login failed for phone: %s", phone)
|
||||||
|
error = "Invalid phone number or passcode."
|
||||||
|
|
||||||
|
return render_template("tenant/staff_auth/login.html", error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_auth_bp.route("/staff-logout")
|
||||||
|
@login_required
|
||||||
|
def staff_logout():
|
||||||
|
logout_user()
|
||||||
|
session.clear()
|
||||||
|
logger.info("Staff logout")
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/staff_portal/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/waitlist/routes.py
|
||||||
|
Phase 3+ implementation.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
config.py
|
||||||
|
Configuration classes for Development, Production, and Testing.
|
||||||
|
Loaded by both app factories via: from config import config
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-fallback-secret-change-me")
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
|
"DATABASE_URL", "mysql+pymysql://root:password@localhost/salon_pos"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 280, "pool_pre_ping": True}
|
||||||
|
MAIL_SERVER = os.environ.get("MAIL_SERVER", "localhost")
|
||||||
|
MAIL_PORT = int(os.environ.get("MAIL_PORT", 587))
|
||||||
|
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS", "true").lower() == "true"
|
||||||
|
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||||
|
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
|
||||||
|
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_USERNAME", "noreply@mydomain.com")
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", 900)))
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(seconds=int(os.environ.get("JWT_REFRESH_TOKEN_EXPIRES", 604800)))
|
||||||
|
JWT_TOKEN_LOCATION = ["cookies"]
|
||||||
|
JWT_COOKIE_SECURE = True
|
||||||
|
JWT_COOKIE_SAMESITE = "Lax"
|
||||||
|
JWT_COOKIE_CSRF_PROTECT = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://")
|
||||||
|
RATELIMIT_DEFAULT = "200 per minute"
|
||||||
|
RATELIMIT_ENABLED = True
|
||||||
|
ADMIN_DOMAIN = os.environ.get("ADMIN_DOMAIN", "admin.mydomain.com")
|
||||||
|
TENANT_DOMAIN = os.environ.get("TENANT_DOMAIN", "mydomain.com")
|
||||||
|
DEMO_TENANT_SLUG = os.environ.get("DEMO_TENANT_SLUG", "demo")
|
||||||
|
ADMIN_IP_ALLOWLIST = os.environ.get("ADMIN_IP_ALLOWLIST", "")
|
||||||
|
MAX_LOGIN_ATTEMPTS = int(os.environ.get("MAX_LOGIN_ATTEMPTS", 5))
|
||||||
|
LOGIN_LOCKOUT_MINUTES = int(os.environ.get("LOGIN_LOCKOUT_MINUTES", 15))
|
||||||
|
STAFF_PASSCODE_MIN_LENGTH = int(os.environ.get("STAFF_PASSCODE_MIN_LENGTH", 4))
|
||||||
|
STAFF_PASSCODE_MAX_LENGTH = int(os.environ.get("STAFF_PASSCODE_MAX_LENGTH", 6))
|
||||||
|
MAX_CONTENT_LENGTH = 5 * 1024 * 1024
|
||||||
|
UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif"}
|
||||||
|
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "/var/www/salon_pos_uploads")
|
||||||
|
WTF_CSRF_ENABLED = True
|
||||||
|
WTF_CSRF_TIME_LIMIT = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
|
"DATABASE_URL", "mysql+pymysql://root:password@localhost/salon_pos_dev"
|
||||||
|
)
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
JWT_COOKIE_SECURE = False
|
||||||
|
RATELIMIT_ENABLED = False
|
||||||
|
SESSION_TIMEOUT_TENANT = 1800
|
||||||
|
SESSION_TIMEOUT_ADMIN = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
DEBUG = False
|
||||||
|
TESTING = False
|
||||||
|
SESSION_TIMEOUT_TENANT = int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800))
|
||||||
|
SESSION_TIMEOUT_ADMIN = int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600))
|
||||||
|
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
TESTING = True
|
||||||
|
DEBUG = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
JWT_COOKIE_SECURE = False
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
RATELIMIT_ENABLED = False
|
||||||
|
SESSION_TIMEOUT_TENANT = 1800
|
||||||
|
SESSION_TIMEOUT_ADMIN = 3600
|
||||||
|
BCRYPT_LOG_ROUNDS = 4
|
||||||
|
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"development": DevelopmentConfig,
|
||||||
|
"production": ProductionConfig,
|
||||||
|
"testing": TestingConfig,
|
||||||
|
"default": DevelopmentConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_config():
|
||||||
|
"""Return the config class for the current FLASK_ENV. Used by app factories."""
|
||||||
|
import os
|
||||||
|
env = os.environ.get("FLASK_ENV", "development")
|
||||||
|
return config.get(env, DevelopmentConfig)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0 2 * * * salonpos /opt/salon_pos/deploy/backup/db_backup.sh >> /var/log/salon_pos_backup.log 2>&1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/salon_pos}"
|
||||||
|
DB_NAME="salon_pos"
|
||||||
|
RETAIN_DAYS="${BACKUP_RETAIN_DAYS:-30}"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
mysqldump --defaults-file=/etc/mysql/backup.cnf "$DB_NAME" \
|
||||||
|
| gzip > "$BACKUP_DIR/salon_pos_$TIMESTAMP.sql.gz"
|
||||||
|
|
||||||
|
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +"$RETAIN_DAYS" -delete
|
||||||
|
echo "[$(date)] Backup completed: salon_pos_$TIMESTAMP.sql.gz"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ── Admin portal ────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name admin.mydomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/mydomain.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/mydomain.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
allow 203.0.113.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/salon_pos_admin.sock;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/admin/ {
|
||||||
|
alias /opt/salon_pos/app/static/admin/;
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Tenant portal ────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name mydomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/mydomain.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/mydomain.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/salon_pos_tenant.sock;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/tenant/ {
|
||||||
|
alias /opt/salon_pos/app/static/tenant/;
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name admin.mydomain.com mydomain.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nails Salon POS — Admin Portal (Gunicorn)
|
||||||
|
After=network.target mysql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=salonpos
|
||||||
|
WorkingDirectory=/opt/salon_pos
|
||||||
|
EnvironmentFile=/opt/salon_pos/.env
|
||||||
|
ExecStart=/opt/salon_pos/venv/bin/gunicorn \
|
||||||
|
--workers 2 \
|
||||||
|
--bind unix:/run/salon_pos_admin.sock \
|
||||||
|
--timeout 120 \
|
||||||
|
wsgi_admin:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nails Salon POS — Tenant Portal (Gunicorn)
|
||||||
|
After=network.target mysql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=salonpos
|
||||||
|
WorkingDirectory=/opt/salon_pos
|
||||||
|
EnvironmentFile=/opt/salon_pos/.env
|
||||||
|
ExecStart=/opt/salon_pos/venv/bin/gunicorn \
|
||||||
|
--workers 4 \
|
||||||
|
--bind unix:/run/salon_pos_tenant.sock \
|
||||||
|
--timeout 120 \
|
||||||
|
wsgi_tenant:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-JWT-Extended==4.6.0
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
Flask-WTF==1.2.1
|
||||||
|
Flask-Limiter==3.7.0
|
||||||
|
Flask-Mail==0.10.0
|
||||||
|
Flask-APScheduler==1.13.1
|
||||||
|
SQLAlchemy==2.0.30
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
PyMySQL==1.1.1
|
||||||
|
bcrypt==4.1.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
WeasyPrint==62.3
|
||||||
|
WTForms==3.1.2
|
||||||
|
gunicorn==22.0.0
|
||||||
|
cryptography==42.0.8
|
||||||
|
pytest==8.2.2
|
||||||
|
pytest-flask==1.3.0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* Admin portal custom styles */
|
||||||
|
.sidebar { height: calc(100vh - 56px); overflow-y: auto; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* Tenant portal custom styles */
|
||||||
|
.sidebar { height: calc(100vh - 56px); overflow-y: auto; position: sticky; top: 56px; }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}Admin Login{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
|
<div class="card shadow" style="width: 400px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-shield-lock-fill fs-1 text-dark"></i>
|
||||||
|
<h4 class="mt-2 fw-bold">Admin Portal</h4>
|
||||||
|
<p class="text-muted small">Restricted access</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('admin_auth.login') }}" novalidate>
|
||||||
|
{{ csrf_token() if csrf_token is defined }}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email address</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
|
autocomplete="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid mt-4">
|
||||||
|
<button type="submit" class="btn btn-dark">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('admin_auth.password_reset_request') }}" class="text-muted small">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}Set New Password{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
|
<div class="card shadow" style="width: 420px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-bold mb-3">Set New Password</h5>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">New password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
autocomplete="new-password" required autofocus>
|
||||||
|
<div class="form-text">Min 10 characters. Must include uppercase, lowercase, and a digit.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">Confirm password</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password"
|
||||||
|
name="confirm_password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-dark">Update Password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}Reset Password{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
|
<div class="card shadow" style="width: 400px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-bold mb-3">Reset Admin Password</h5>
|
||||||
|
<p class="text-muted small">Enter your registered email address and we'll send a reset link.</p>
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email address</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-dark">Send Reset Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('admin_auth.login') }}" class="text-muted small">Back to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Admin Portal{% endblock %} — Salon POS</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand fw-bold" href="{{ url_for('tenants.index') }}">
|
||||||
|
<i class="bi bi-shield-lock-fill me-2"></i>Admin Portal
|
||||||
|
</a>
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<span class="navbar-text text-light me-3">{{ current_user.name }}</span>
|
||||||
|
<a class="nav-link text-warning" href="{{ url_for('admin_auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="col-md-2 d-none d-md-block bg-white border-end vh-100 pt-3 position-sticky top-0">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('tenants') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('tenants.index') }}">
|
||||||
|
<i class="bi bi-building me-2"></i>Tenants
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('system_users') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('system_users.index') }}">
|
||||||
|
<i class="bi bi-people me-2"></i>System Users
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('plans') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('plans.index') }}">
|
||||||
|
<i class="bi bi-card-list me-2"></i>Plans
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('billing') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('billing.index') }}">
|
||||||
|
<i class="bi bi-receipt me-2"></i>Billing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('audit_log') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('audit_log.index') }}">
|
||||||
|
<i class="bi bi-journal-text me-2"></i>Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('analytics') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('analytics.index') }}">
|
||||||
|
<i class="bi bi-bar-chart me-2"></i>Analytics
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="col-md-10 ms-sm-auto px-4 py-3">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-2" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block title %}Account Suspended{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 80vh;">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-lock-fill display-1 text-danger"></i>
|
||||||
|
<h3 class="mt-3">Account Suspended</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
Your account has been suspended. Please contact support to resolve any outstanding issues.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary mt-2">
|
||||||
|
Sign Out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user