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
|
||||
- **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)
|
||||
- **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
|
||||
- **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
|
||||
@@ -671,7 +671,7 @@ WantedBy=multi-user.target
|
||||
- [ ] 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
|
||||
- [ ] 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)
|
||||
- [ ] 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)
|
||||
@@ -764,7 +764,7 @@ WantedBy=multi-user.target
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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`. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -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