feat: subdomain routing por tenant — refac-xxx.nexusautoparts.com
- Nginx wildcard config: *.nexusautoparts.com routes to POS app with X-Tenant-Subdomain header - middleware_tenant.py: resolves subdomain -> tenant_id via nexus_master.tenants.subdomain - auth_bp: login and employee list endpoints accept tenant from subdomain, URL param, or body - login.html: auto-detects tenant from subdomain, shows business name, falls back to ?tenant=ID - tenant_manager: generates subdomain slug from business name on provision_tenant() - Migration v1.2: adds subdomain column + unique index to tenants table - setup-nginx.sh: one-command install script for the nginx config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
"""Create and manage tenant databases."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
@@ -12,6 +14,24 @@ MIGRATIONS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migra
|
||||
SEED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'seed')
|
||||
|
||||
|
||||
def generate_subdomain(name):
|
||||
"""Generate a URL-safe subdomain from a business name.
|
||||
|
||||
Examples:
|
||||
'Refaccionaria López' -> 'refaccionaria-lopez'
|
||||
'Auto Parts MX #3' -> 'auto-parts-mx-3'
|
||||
"""
|
||||
# Normalize unicode (strip accents)
|
||||
nfkd = unicodedata.normalize('NFKD', name)
|
||||
ascii_name = nfkd.encode('ascii', 'ignore').decode('ascii')
|
||||
# Lowercase, replace non-alphanumeric with hyphens
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', ascii_name.lower()).strip('-')
|
||||
# Collapse multiple hyphens
|
||||
slug = re.sub(r'-{2,}', '-', slug)
|
||||
# Truncate to 100 chars (column limit)
|
||||
return slug[:100]
|
||||
|
||||
|
||||
def ensure_master_tables():
|
||||
"""Create tenants/subscriptions/schema_version tables in nexus_master if missing."""
|
||||
conn = get_master_conn()
|
||||
@@ -21,12 +41,20 @@ def ensure_master_tables():
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
db_name VARCHAR(100) UNIQUE NOT NULL,
|
||||
subdomain VARCHAR(100) UNIQUE,
|
||||
rfc VARCHAR(13),
|
||||
plan VARCHAR(50) DEFAULT 'basic',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
# Add subdomain column if table already existed without it
|
||||
cur.execute("""
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS subdomain VARCHAR(100) UNIQUE
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain)
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -88,23 +116,30 @@ def create_template_db():
|
||||
return True # Created
|
||||
|
||||
|
||||
def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000"):
|
||||
"""Create a new tenant: register in master, create DB from template, create owner employee."""
|
||||
def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000", subdomain=None):
|
||||
"""Create a new tenant: register in master, create DB from template, create owner employee.
|
||||
|
||||
If subdomain is not provided, one is auto-generated from the business name.
|
||||
"""
|
||||
import bcrypt
|
||||
|
||||
ensure_master_tables()
|
||||
create_template_db()
|
||||
|
||||
# Generate subdomain if not provided
|
||||
if not subdomain:
|
||||
subdomain = generate_subdomain(name)
|
||||
|
||||
# Generate db_name
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Insert tenant
|
||||
cur.execute("""
|
||||
INSERT INTO tenants (name, db_name, rfc)
|
||||
VALUES (%s, %s, %s)
|
||||
INSERT INTO tenants (name, db_name, rfc, subdomain)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, db_name
|
||||
""", (name, f"tenant_{name.lower().replace(' ', '_')[:30]}", rfc))
|
||||
""", (name, f"tenant_{name.lower().replace(' ', '_')[:30]}", rfc, subdomain))
|
||||
tenant_id, db_name = cur.fetchone()
|
||||
|
||||
# Track schema version
|
||||
@@ -181,20 +216,20 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
|
||||
tenant_cur.close()
|
||||
tenant_conn.close()
|
||||
|
||||
return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id}
|
||||
return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id, 'subdomain': subdomain}
|
||||
|
||||
|
||||
def list_tenants():
|
||||
"""List all tenants."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, name, db_name, rfc, plan, is_active, created_at FROM tenants ORDER BY id")
|
||||
cur.execute("SELECT id, name, db_name, rfc, plan, is_active, created_at, subdomain FROM tenants ORDER BY id")
|
||||
tenants = []
|
||||
for row in cur.fetchall():
|
||||
tenants.append({
|
||||
'id': row[0], 'name': row[1], 'db_name': row[2],
|
||||
'rfc': row[3], 'plan': row[4], 'is_active': row[5],
|
||||
'created_at': str(row[6])
|
||||
'created_at': str(row[6]), 'subdomain': row[7]
|
||||
})
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user