Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""Normalize contact model: fix surfix typo, Float→Numeric(5,2) discounts, JSON→JSONB, unique constraints.
|
||||
|
||||
Revision ID: 0039_contact_normalize
|
||||
Revises: 0038_dms_content_hash
|
||||
Create Date: 2026-07-25
|
||||
|
||||
Changes:
|
||||
1. Rename column surfix → suffix on contacts table.
|
||||
2. Convert discount_* columns from Float to Numeric(5,2) with CHECK constraints (0-100).
|
||||
3. Convert custom columns from JSON to JSONB on contacts and contactpersons.
|
||||
4. Add partial unique constraints: (tenant_id, code) and (tenant_id, accounting_code) where NOT NULL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "0039_contact_normalize"
|
||||
down_revision: Union[str, None] = "0038_dms_content_hash"
|
||||
branch_labels: Union[str, None] = None
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
DISCOUNT_COLUMNS = [
|
||||
"discount_crew",
|
||||
"discount_transport",
|
||||
"discount_rental",
|
||||
"discount_sale",
|
||||
"discount_subrent",
|
||||
"discount_total",
|
||||
]
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> str:
|
||||
"""Return SQL that checks if a column exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.columns "
|
||||
f"WHERE table_name = '{table}' AND column_name = '{column}'"
|
||||
)
|
||||
|
||||
|
||||
def _constraint_exists(table: str, constraint: str) -> str:
|
||||
"""Return SQL that checks if a constraint exists on a table."""
|
||||
return (
|
||||
f"SELECT 1 FROM information_schema.table_constraints "
|
||||
f"WHERE table_name = '{table}' AND constraint_name = '{constraint}'"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1a. Rename surfix → suffix ──
|
||||
result = conn.execute(sa.text(_column_exists("contacts", "surfix"))).fetchone()
|
||||
if result:
|
||||
op.alter_column("contacts", "surfix", new_column_name="suffix")
|
||||
|
||||
# ── 1b. Convert discount_* from Float to Numeric(5,2) with CHECK ──
|
||||
for col in DISCOUNT_COLUMNS:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE contacts ALTER COLUMN {col} "
|
||||
f"TYPE NUMERIC(5,2) USING {col}::numeric(5,2)"
|
||||
)
|
||||
)
|
||||
# Add CHECK constraint if not exists
|
||||
ck_name = f"ck_contacts_{col}_range"
|
||||
ck_exists = conn.execute(
|
||||
sa.text(_constraint_exists("contacts", ck_name))
|
||||
).fetchone()
|
||||
if not ck_exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE contacts ADD CONSTRAINT {ck_name} "
|
||||
f"CHECK ({col} BETWEEN 0 AND 100)"
|
||||
)
|
||||
)
|
||||
|
||||
# ── 1c. JSON → JSONB for contacts.custom ──
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'contacts' AND column_name = 'custom'"
|
||||
)
|
||||
).fetchone()
|
||||
if result and result[0] == "json":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contacts ALTER COLUMN custom "
|
||||
"TYPE JSONB USING custom::jsonb"
|
||||
)
|
||||
)
|
||||
|
||||
# ── 1d. JSON → JSONB for contactpersons.custom ──
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'contactpersons' AND column_name = 'custom'"
|
||||
)
|
||||
).fetchone()
|
||||
if result and result[0] == "json":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contactpersons ALTER COLUMN custom "
|
||||
"TYPE JSONB USING custom::jsonb"
|
||||
)
|
||||
)
|
||||
|
||||
# ── 1e. Partial unique constraints ──
|
||||
# (tenant_id, code) where code IS NOT NULL
|
||||
uq_code_exists = conn.execute(
|
||||
sa.text(_constraint_exists("contacts", "uq_contacts_tenant_code"))
|
||||
).fetchone()
|
||||
if not uq_code_exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE UNIQUE INDEX uq_contacts_tenant_code "
|
||||
"ON contacts (tenant_id, code) WHERE code IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
# (tenant_id, accounting_code) where accounting_code IS NOT NULL
|
||||
uq_acct_exists = conn.execute(
|
||||
sa.text(_constraint_exists("contacts", "uq_contacts_tenant_accounting_code"))
|
||||
).fetchone()
|
||||
if not uq_acct_exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE UNIQUE INDEX uq_contacts_tenant_accounting_code "
|
||||
"ON contacts (tenant_id, accounting_code) WHERE accounting_code IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop unique indexes
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS uq_contacts_tenant_accounting_code"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS uq_contacts_tenant_code"))
|
||||
|
||||
# JSONB → JSON
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contactpersons ALTER COLUMN custom "
|
||||
"TYPE JSON USING custom::json"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE contacts ALTER COLUMN custom TYPE JSON USING custom::json"
|
||||
)
|
||||
)
|
||||
|
||||
# Drop CHECK constraints and revert Numeric → Float
|
||||
for col in DISCOUNT_COLUMNS:
|
||||
ck_name = f"ck_contacts_{col}_range"
|
||||
conn.execute(sa.text(f"ALTER TABLE contacts DROP CONSTRAINT IF EXISTS {ck_name}"))
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE contacts ALTER COLUMN {col} "
|
||||
f"TYPE FLOAT USING {col}::float"
|
||||
)
|
||||
)
|
||||
|
||||
# Rename suffix → surfix
|
||||
result = conn.execute(sa.text(_column_exists("contacts", "suffix"))).fetchone()
|
||||
if result:
|
||||
op.alter_column("contacts", "suffix", new_column_name="surfix")
|
||||
Reference in New Issue
Block a user