Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
APP_NAME="Audit Firm v2"
|
||||||
|
ENV="dev"
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
|
# IMPORTANT: change in production
|
||||||
|
SECRET_KEY="change-me-to-a-long-random-string"
|
||||||
|
|
||||||
|
# Cookie/security
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
COOKIE_SAMESITE="lax" # lax|strict|none
|
||||||
|
COOKIE_SESSION_NAME="af2sid"
|
||||||
|
|
||||||
|
# DB backend
|
||||||
|
DB_BACKEND="sqlite" # sqlite|postgres
|
||||||
|
|
||||||
|
SQLITE_COMMON_PATH="./data/common.db"
|
||||||
|
|
||||||
|
ERP_PUBLIC_BASE_URL=http://localhost:8000
|
||||||
|
DEV_AUTH_OTP_PRINT=false
|
||||||
|
|
||||||
|
# Postgres placeholders (later)
|
||||||
|
PG_HOST="127.0.0.1"
|
||||||
|
PG_PORT=5432
|
||||||
|
PG_USER="postgres"
|
||||||
|
PG_PASSWORD="postgres"
|
||||||
|
PG_DB_COMMON="audit_common"
|
||||||
|
|
||||||
|
# Defaults (context fallback)
|
||||||
|
DEFAULT_TENANT_CODE="default"
|
||||||
|
DEFAULT_BRANCH_CODE="main"
|
||||||
|
DEFAULT_YEAR_CODE="2025-26"
|
||||||
|
DEFAULT_TIMEZONE="Asia/Kolkata"
|
||||||
|
|
||||||
|
# Security hardening: do not trust browser/client supplied context headers in public deployment.
|
||||||
|
# Keep false for production unless an internal proxy/test runner is explicitly trusted.
|
||||||
|
TRUST_CONTEXT_HEADERS=false
|
||||||
|
TRUST_CONTEXT_HEADER_HOSTS="127.0.0.1,localhost,::1"
|
||||||
|
# Optional shared secret for trusted internal callers. If set, caller must send
|
||||||
|
# X-AuditFirm-Context-Secret with this value before context headers are accepted.
|
||||||
|
CONTEXT_HEADER_SECRET=""
|
||||||
|
|
||||||
|
# Bootstrap admin (seeded if users table is empty)
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL="admin@auditfirm.local"
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD="ChangeMe@123"
|
||||||
|
|
||||||
|
# JWT for API clients (mobile/apps/integrations)
|
||||||
|
JWT_ISSUER="audit_firm_v2"
|
||||||
|
JWT_AUDIENCE="audit_firm_clients"
|
||||||
|
JWT_ACCESS_MINUTES=15
|
||||||
|
JWT_REFRESH_DAYS=30
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Keep source code and config as LF
|
||||||
|
*.py text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.ini text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
.dockerignore text eol=lf
|
||||||
|
.gitignore text eol=lf
|
||||||
|
.env.example text eol=lf
|
||||||
|
|
||||||
|
# Windows batch files should remain CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.docx binary
|
||||||
|
*.xlsx binary
|
||||||
|
*.zip binary
|
||||||
|
*.db binary
|
||||||
|
*.sqlite binary
|
||||||
|
*.sqlite3 binary
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite3
|
||||||
|
.pytest_cache/
|
||||||
|
venv/
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = sqlite:///./data/common.db
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers = console
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Alembic integration for the Common DB baseline.
|
||||||
|
|
||||||
|
Create migration:
|
||||||
|
alembic revision --autogenerate -m "message"
|
||||||
|
|
||||||
|
Apply migrations:
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
Downgrade:
|
||||||
|
alembic downgrade -1
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from sqlalchemy.engine import URL
|
||||||
|
|
||||||
|
from app.core.settings import get_settings
|
||||||
|
from app.core.db.common import CommonBase
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# IMPORTANT FOR ALEMBIC AUTOGENERATE
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Alembic only sees tables that are registered in CommonBase.metadata.
|
||||||
|
# A model is registered only after its module is imported. Keep every SQLAlchemy
|
||||||
|
# model module here so a fresh baseline migration can generate all tables.
|
||||||
|
# Do not remove these imports even if they look unused.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Core / tenancy models
|
||||||
|
from app.modules.core.tenancy import models as tenancy_models # noqa: F401
|
||||||
|
from app.modules.core.tenancy import settings_models as tenancy_settings_models # noqa: F401
|
||||||
|
|
||||||
|
# IAM / RBAC / token / password-flow models
|
||||||
|
from app.modules.core.iam import models as iam_models # noqa: F401
|
||||||
|
from app.modules.core.iam import tokens_models as iam_tokens_models # noqa: F401
|
||||||
|
from app.modules.core.iam import password_flows_models as iam_password_flows_models # noqa: F401
|
||||||
|
from app.modules.core.rbac import models as rbac_models # noqa: F401
|
||||||
|
|
||||||
|
# Audit log models
|
||||||
|
from app.modules.core.audit import models as audit_models # noqa: F401
|
||||||
|
|
||||||
|
# Business module models
|
||||||
|
from app.modules.clients import models as client_models # noqa: F401
|
||||||
|
from app.modules.clients import association_models as client_association_models # noqa: F401
|
||||||
|
from app.modules.services import models as service_models # noqa: F401
|
||||||
|
from app.modules.consultants import models as consultant_models # noqa: F401
|
||||||
|
from app.modules.employees import models as employee_models # noqa: F401
|
||||||
|
from app.modules.billing import models as billing_models # noqa: F401
|
||||||
|
from app.modules.platform_billing import models as platform_billing_models # noqa: F401
|
||||||
|
from app.modules.marketplace import models as marketplace_models # noqa: F401
|
||||||
|
from app.modules.documents import models as documents_models # noqa: F401
|
||||||
|
from app.modules.alerts import models as alerts_models # noqa: F401
|
||||||
|
from app.modules.email_integration import models as email_integration_models # noqa: F401
|
||||||
|
from app.modules.domain_management import models as domain_management_models # noqa: F401
|
||||||
|
from app.modules.notice_cases import models as notice_cases_models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = CommonBase.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_url() -> str:
|
||||||
|
s = get_settings()
|
||||||
|
if (s.DB_BACKEND or "sqlite").lower() == "sqlite":
|
||||||
|
return f"sqlite:///{s.SQLITE_COMMON_PATH}"
|
||||||
|
return URL.create(
|
||||||
|
drivername="postgresql+psycopg",
|
||||||
|
username=s.PG_USER,
|
||||||
|
password=s.PG_PASSWORD,
|
||||||
|
host=s.PG_HOST,
|
||||||
|
port=s.PG_PORT,
|
||||||
|
database=s.PG_DB_COMMON,
|
||||||
|
).render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=get_url(),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section) or {}
|
||||||
|
configuration["sqlalchemy.url"] = get_url()
|
||||||
|
connectable = engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
render_as_batch=("sqlite" in get_url()),
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""release_a_clients_engagement_mode
|
||||||
|
|
||||||
|
Revision ID: 20260414_release_a_clients
|
||||||
|
Revises: 7f1c9b2d4a10
|
||||||
|
Create Date: 2026-04-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260414_release_a_clients"
|
||||||
|
down_revision = "7f1c9b2d4a10"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# engagement_mode column already exists from the earlier partial migration,
|
||||||
|
# so do NOT add it again.
|
||||||
|
|
||||||
|
# Make partner_id nullable in a SQLite-safe way
|
||||||
|
with op.batch_alter_table("clients") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"partner_id",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove server default from engagement_mode if it exists
|
||||||
|
with op.batch_alter_table("clients") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"engagement_mode",
|
||||||
|
existing_type=sa.String(length=32),
|
||||||
|
server_default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Revert partner_id back to NOT NULL
|
||||||
|
with op.batch_alter_table("clients") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"partner_id",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop engagement_mode
|
||||||
|
with op.batch_alter_table("clients") as batch_op:
|
||||||
|
batch_op.drop_column("engagement_mode")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260415_release_b1_b2"
|
||||||
|
down_revision = "20260414_release_a_clients"
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"client_associations",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("association_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("firm_tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_source", sa.String(50), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("client_associations")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260416_release_b6_cleanup"
|
||||||
|
down_revision = "20260415_release_b1_b2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.tenant_id, c.branch_id, c.partner_id
|
||||||
|
FROM clients c
|
||||||
|
LEFT JOIN client_associations a
|
||||||
|
ON a.client_id = c.id
|
||||||
|
WHERE a.id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
association_type = "firm" if row.tenant_id else "self_service_unassigned"
|
||||||
|
created_source = "system_admin" if row.tenant_id else "self_service"
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO client_associations
|
||||||
|
(
|
||||||
|
client_id,
|
||||||
|
association_type,
|
||||||
|
firm_tenant_id,
|
||||||
|
consultant_id,
|
||||||
|
partner_user_id,
|
||||||
|
created_source
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
:client_id,
|
||||||
|
:association_type,
|
||||||
|
:firm_tenant_id,
|
||||||
|
:consultant_id,
|
||||||
|
:partner_user_id,
|
||||||
|
:created_source
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"client_id": row.id,
|
||||||
|
"association_type": association_type,
|
||||||
|
"firm_tenant_id": row.tenant_id,
|
||||||
|
"consultant_id": None,
|
||||||
|
"partner_user_id": row.partner_id,
|
||||||
|
"created_source": created_source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM client_associations
|
||||||
|
WHERE created_source IN ('system_admin', 'self_service')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260419_client_portal_user_link"
|
||||||
|
down_revision = "20260416_release_b6_cleanup"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table("clients") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("portal_user_id", sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_index("ix_clients_portal_user_id", ["portal_user_id"], unique=False)
|
||||||
|
batch_op.create_foreign_key("fk_clients_portal_user_id_users", "users", ["portal_user_id"], ["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("clients") as batch_op:
|
||||||
|
batch_op.drop_constraint("fk_clients_portal_user_id_users", type_="foreignkey")
|
||||||
|
batch_op.drop_index("ix_clients_portal_user_id")
|
||||||
|
batch_op.drop_column("portal_user_id")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""split services into system catalogue and firm task templates
|
||||||
|
|
||||||
|
Revision ID: 20260420_services_catalogue_and_firm_templates
|
||||||
|
Revises: 20260419_client_portal_user_link
|
||||||
|
Create Date: 2026-04-20 18:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "20260420_services_catalogue_and_firm_templates"
|
||||||
|
down_revision: Union[str, None] = "20260419_client_portal_user_link"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name: str) -> bool:
|
||||||
|
return name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("service_catalogues"):
|
||||||
|
op.create_table(
|
||||||
|
"service_catalogues",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("service_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("service_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("recurrence_type", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_client_requestable", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("service_code", name="uq_service_catalogues_code"),
|
||||||
|
)
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_service_code", ["service_code"])
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_service_name", ["service_name"])
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_category", ["category"])
|
||||||
|
|
||||||
|
if not _has_table("firm_service_task_templates"):
|
||||||
|
op.create_table(
|
||||||
|
"firm_service_task_templates",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("task_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sequence_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("default_role_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("sla_days", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("requires_review", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "service_catalogue_id", "sequence_no", name="uq_firm_service_task_sequence"),
|
||||||
|
)
|
||||||
|
_create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_service_catalogue_id", ["service_catalogue_id"])
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _has_table("services"):
|
||||||
|
bind.execute(sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_catalogues (
|
||||||
|
service_code, service_name, category, description, recurrence_type,
|
||||||
|
is_active, is_client_requestable, is_consultant_requestable,
|
||||||
|
created_by_user_id, updated_by_user_id, created_at_utc, updated_at_utc
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.service_code,
|
||||||
|
MIN(s.service_name) AS service_name,
|
||||||
|
MIN(s.category) AS category,
|
||||||
|
MIN(s.description) AS description,
|
||||||
|
NULL AS recurrence_type,
|
||||||
|
MAX(CASE WHEN s.is_active THEN 1 ELSE 0 END) AS is_active,
|
||||||
|
MAX(CASE WHEN s.is_client_requestable THEN 1 ELSE 0 END) AS is_client_requestable,
|
||||||
|
MAX(CASE WHEN s.is_consultant_requestable THEN 1 ELSE 0 END) AS is_consultant_requestable,
|
||||||
|
MIN(s.created_by_user_id) AS created_by_user_id,
|
||||||
|
MIN(s.updated_by_user_id) AS updated_by_user_id,
|
||||||
|
MIN(s.created_at_utc) AS created_at_utc,
|
||||||
|
MAX(s.updated_at_utc) AS updated_at_utc
|
||||||
|
FROM services s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM service_catalogues sc WHERE sc.service_code = s.service_code
|
||||||
|
)
|
||||||
|
GROUP BY s.service_code
|
||||||
|
"""
|
||||||
|
))
|
||||||
|
|
||||||
|
if _has_table("services") and _has_table("service_task_templates"):
|
||||||
|
bind.execute(sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO firm_service_task_templates (
|
||||||
|
tenant_id, branch_id, service_catalogue_id,
|
||||||
|
task_name, description, sequence_no, default_role_name, sla_days,
|
||||||
|
is_mandatory, requires_review, is_active,
|
||||||
|
created_by_user_id, updated_by_user_id, created_at_utc, updated_at_utc
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.tenant_id,
|
||||||
|
s.branch_id,
|
||||||
|
sc.id,
|
||||||
|
st.task_name,
|
||||||
|
st.description,
|
||||||
|
st.sequence_no,
|
||||||
|
st.default_role_name,
|
||||||
|
NULL AS sla_days,
|
||||||
|
st.is_mandatory,
|
||||||
|
st.requires_review,
|
||||||
|
st.is_active,
|
||||||
|
st.created_by_user_id,
|
||||||
|
st.updated_by_user_id,
|
||||||
|
st.created_at_utc,
|
||||||
|
st.updated_at_utc
|
||||||
|
FROM service_task_templates st
|
||||||
|
JOIN services s ON s.id = st.service_id
|
||||||
|
JOIN service_catalogues sc ON sc.service_code = s.service_code
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM firm_service_task_templates ft
|
||||||
|
WHERE ft.tenant_id = s.tenant_id
|
||||||
|
AND ft.service_catalogue_id = sc.id
|
||||||
|
AND ft.sequence_no = st.sequence_no
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260420_services_catalogue_firm_toggle"
|
||||||
|
down_revision = "20260420_services_catalogue_and_firm_templates"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name: str) -> bool:
|
||||||
|
return name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not _has_table("firm_service_selections"):
|
||||||
|
op.create_table(
|
||||||
|
"firm_service_selections",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("default_branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("activated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("activated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["default_branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["activated_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "service_catalogue_id", name="uq_firm_service_selections_tenant_catalogue"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing(
|
||||||
|
"firm_service_selections",
|
||||||
|
"ix_firm_service_selections_tenant_id",
|
||||||
|
["tenant_id"],
|
||||||
|
)
|
||||||
|
_create_index_if_missing(
|
||||||
|
"firm_service_selections",
|
||||||
|
"ix_firm_service_selections_service_catalogue_id",
|
||||||
|
["service_catalogue_id"],
|
||||||
|
)
|
||||||
|
_create_index_if_missing(
|
||||||
|
"firm_service_selections",
|
||||||
|
"ix_firm_service_selections_default_branch_id",
|
||||||
|
["default_branch_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if _has_table("services") and _has_table("service_catalogues"):
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO firm_service_selections (
|
||||||
|
tenant_id,
|
||||||
|
service_catalogue_id,
|
||||||
|
is_enabled,
|
||||||
|
default_branch_id,
|
||||||
|
activated_by_user_id,
|
||||||
|
updated_by_user_id,
|
||||||
|
activated_at_utc,
|
||||||
|
updated_at_utc
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.tenant_id,
|
||||||
|
sc.id,
|
||||||
|
MAX(CASE WHEN s.is_active THEN 1 ELSE 0 END) AS is_enabled,
|
||||||
|
MIN(s.branch_id) AS default_branch_id,
|
||||||
|
MIN(s.created_by_user_id) AS activated_by_user_id,
|
||||||
|
MIN(s.updated_by_user_id) AS updated_by_user_id,
|
||||||
|
MIN(s.created_at_utc) AS activated_at_utc,
|
||||||
|
MAX(s.updated_at_utc) AS updated_at_utc
|
||||||
|
FROM services s
|
||||||
|
JOIN service_catalogues sc
|
||||||
|
ON sc.service_code = s.service_code
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM firm_service_selections fss
|
||||||
|
WHERE fss.tenant_id = s.tenant_id
|
||||||
|
AND fss.service_catalogue_id = sc.id
|
||||||
|
)
|
||||||
|
GROUP BY s.tenant_id, sc.id
|
||||||
|
"""
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if _has_table("firm_service_selections"):
|
||||||
|
op.drop_table("firm_service_selections")
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""services phase s1: categories, recurrence, sort order, applicability flags
|
||||||
|
|
||||||
|
Revision ID: 20260422_services_phase_s1
|
||||||
|
Revises: 20260420_services_catalogue_firm_toggle
|
||||||
|
Create Date: 2026-04-22 20:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260422_services_phase_s1"
|
||||||
|
down_revision = "20260420_services_catalogue_firm_toggle"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name: str) -> bool:
|
||||||
|
return name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
cols = _inspector().get_columns(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(col.get("name") == column_name for col in cols)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_code(name: str) -> str:
|
||||||
|
text = (name or "").strip().upper()
|
||||||
|
out = []
|
||||||
|
last_sep = False
|
||||||
|
for ch in text:
|
||||||
|
if ch.isalnum():
|
||||||
|
out.append(ch)
|
||||||
|
last_sep = False
|
||||||
|
else:
|
||||||
|
if not last_sep:
|
||||||
|
out.append("_")
|
||||||
|
last_sep = True
|
||||||
|
code = "".join(out).strip("_")
|
||||||
|
return code or "UNCATEGORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if not _has_table("service_categories"):
|
||||||
|
op.create_table(
|
||||||
|
"service_categories",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _has_table("service_categories"):
|
||||||
|
if not _has_column("service_categories", "code"):
|
||||||
|
op.add_column("service_categories", sa.Column("code", sa.String(length=50), nullable=True))
|
||||||
|
if not _has_column("service_categories", "name"):
|
||||||
|
op.add_column("service_categories", sa.Column("name", sa.String(length=100), nullable=True))
|
||||||
|
if not _has_column("service_categories", "sort_order"):
|
||||||
|
op.add_column("service_categories", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
if not _has_column("service_categories", "is_active"):
|
||||||
|
op.add_column("service_categories", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
|
conn.execute(sa.text(
|
||||||
|
"UPDATE service_categories SET name = COALESCE(name, 'Category ' || id) WHERE name IS NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
rows = conn.execute(sa.text(
|
||||||
|
"SELECT id, name FROM service_categories WHERE (code IS NULL OR TRIM(code) = '')"
|
||||||
|
)).fetchall()
|
||||||
|
used_codes = set()
|
||||||
|
existing_code_rows = conn.execute(sa.text(
|
||||||
|
"SELECT code FROM service_categories WHERE code IS NOT NULL AND TRIM(code) <> ''"
|
||||||
|
)).fetchall()
|
||||||
|
for row in existing_code_rows:
|
||||||
|
used_codes.add(row[0])
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
category_id = row[0]
|
||||||
|
name = row[1] or f"Category {category_id}"
|
||||||
|
base_code = _normalize_code(name)
|
||||||
|
code = base_code
|
||||||
|
n = 2
|
||||||
|
while code in used_codes:
|
||||||
|
code = f"{base_code}_{n}"
|
||||||
|
n += 1
|
||||||
|
used_codes.add(code)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE service_categories SET code = :code, name = COALESCE(name, :name) WHERE id = :id"),
|
||||||
|
{"code": code, "name": name, "id": category_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(sa.text("UPDATE service_categories SET sort_order = 0 WHERE sort_order IS NULL"))
|
||||||
|
conn.execute(sa.text("UPDATE service_categories SET is_active = TRUE WHERE is_active IS NULL"))
|
||||||
|
|
||||||
|
_create_index_if_missing("service_categories", "ix_service_categories_code", ["code"])
|
||||||
|
_create_index_if_missing("service_categories", "ix_service_categories_name", ["name"])
|
||||||
|
_create_index_if_missing("service_categories", "ix_service_categories_sort_order", ["sort_order"])
|
||||||
|
|
||||||
|
if _has_table("service_catalogues"):
|
||||||
|
additions = [
|
||||||
|
("category_id", sa.Integer(), True, None),
|
||||||
|
("recurrence_type", sa.String(length=50), True, None),
|
||||||
|
("sort_order", sa.Integer(), False, "0"),
|
||||||
|
("applicable_individual", sa.Boolean(), False, sa.false()),
|
||||||
|
("applicable_proprietorship", sa.Boolean(), False, sa.false()),
|
||||||
|
("applicable_partnership", sa.Boolean(), False, sa.false()),
|
||||||
|
("applicable_llp", sa.Boolean(), False, sa.false()),
|
||||||
|
("applicable_company", sa.Boolean(), False, sa.false()),
|
||||||
|
("applicable_trust", sa.Boolean(), False, sa.false()),
|
||||||
|
("applicable_society", sa.Boolean(), False, sa.false()),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, typ, nullable, default in additions:
|
||||||
|
if not _has_column("service_catalogues", name):
|
||||||
|
kwargs = {"nullable": nullable}
|
||||||
|
if default is not None:
|
||||||
|
kwargs["server_default"] = default
|
||||||
|
op.add_column("service_catalogues", sa.Column(name, typ, **kwargs))
|
||||||
|
|
||||||
|
conn.execute(sa.text("UPDATE service_catalogues SET sort_order = 0 WHERE sort_order IS NULL"))
|
||||||
|
for flag in [
|
||||||
|
"applicable_individual",
|
||||||
|
"applicable_proprietorship",
|
||||||
|
"applicable_partnership",
|
||||||
|
"applicable_llp",
|
||||||
|
"applicable_company",
|
||||||
|
"applicable_trust",
|
||||||
|
"applicable_society",
|
||||||
|
]:
|
||||||
|
if _has_column("service_catalogues", flag):
|
||||||
|
conn.execute(sa.text(f"UPDATE service_catalogues SET {flag} = FALSE WHERE {flag} IS NULL"))
|
||||||
|
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_category_id", ["category_id"])
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_recurrence_type", ["recurrence_type"])
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_sort_order", ["sort_order"])
|
||||||
|
|
||||||
|
if _has_column("service_catalogues", "category"):
|
||||||
|
rows = conn.execute(sa.text(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT TRIM(category) AS category
|
||||||
|
FROM service_catalogues
|
||||||
|
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||||
|
ORDER BY TRIM(category)
|
||||||
|
"""
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
next_sort = conn.execute(sa.text("SELECT COALESCE(MAX(sort_order), 0) FROM service_categories")).scalar() or 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
category_name = row[0]
|
||||||
|
existing = conn.execute(
|
||||||
|
sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"),
|
||||||
|
{"name": category_name},
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
base_code = _normalize_code(category_name)
|
||||||
|
code = base_code
|
||||||
|
n = 2
|
||||||
|
while conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM service_categories WHERE code = :code"),
|
||||||
|
{"code": code},
|
||||||
|
).fetchone():
|
||||||
|
code = f"{base_code}_{n}"
|
||||||
|
n += 1
|
||||||
|
next_sort += 1
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_categories (code, name, sort_order, is_active)
|
||||||
|
VALUES (:code, :name, :sort_order, TRUE)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"code": code, "name": category_name, "sort_order": next_sort},
|
||||||
|
)
|
||||||
|
|
||||||
|
if _has_column("service_catalogues", "category_id"):
|
||||||
|
rows = conn.execute(sa.text(
|
||||||
|
"""
|
||||||
|
SELECT id, category
|
||||||
|
FROM service_catalogues
|
||||||
|
WHERE category_id IS NULL
|
||||||
|
AND category IS NOT NULL
|
||||||
|
AND TRIM(category) <> ''
|
||||||
|
"""
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
catalogue_id = row[0]
|
||||||
|
category_name = row[1].strip()
|
||||||
|
match = conn.execute(
|
||||||
|
sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"),
|
||||||
|
{"name": category_name},
|
||||||
|
).fetchone()
|
||||||
|
if match:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE service_catalogues SET category_id = :category_id WHERE id = :catalogue_id"),
|
||||||
|
{"category_id": match[0], "catalogue_id": catalogue_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""services phase s2: default task templates
|
||||||
|
|
||||||
|
Revision ID: 20260423_services_phase_s2_default_templates
|
||||||
|
Revises: 20260422_services_phase_s1
|
||||||
|
Create Date: 2026-04-23 19:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260423_services_phase_s2_default_templates"
|
||||||
|
down_revision = "20260422_services_phase_s1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name: str) -> bool:
|
||||||
|
return name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not _has_table("service_default_task_templates"):
|
||||||
|
op.create_table(
|
||||||
|
"service_default_task_templates",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("task_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sequence_no", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("default_role_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("requires_review", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("service_catalogue_id", "sequence_no", name="uq_service_default_task_templates_sequence"),
|
||||||
|
)
|
||||||
|
if not _has_index("service_default_task_templates", "ix_service_default_task_templates_service_catalogue_id"):
|
||||||
|
op.create_index("ix_service_default_task_templates_service_catalogue_id", "service_default_task_templates", ["service_catalogue_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if _has_table("service_default_task_templates"):
|
||||||
|
op.drop_table("service_default_task_templates")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""restrict partner service management permissions
|
||||||
|
|
||||||
|
Revision ID: 20260424_partner_service_restrictions
|
||||||
|
Revises: 20260423_services_phase_s2_default_templates
|
||||||
|
Create Date: 2026-04-24 20:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260424_partner_service_restrictions"
|
||||||
|
down_revision = "20260423_services_phase_s2_default_templates"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
PARTNER_REMOVE_PERMISSIONS = (
|
||||||
|
"services.create",
|
||||||
|
"services.edit",
|
||||||
|
"services.deactivate",
|
||||||
|
"services.cross_branch",
|
||||||
|
"services.import",
|
||||||
|
"services.selection.manage",
|
||||||
|
"service_tasks.create",
|
||||||
|
"service_tasks.edit",
|
||||||
|
"service_tasks.import",
|
||||||
|
"service_tasks.deactivate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE role_id IN (SELECT id FROM roles WHERE name = :role_name)
|
||||||
|
AND permission_id IN (SELECT id FROM permissions WHERE code IN :codes)
|
||||||
|
"""
|
||||||
|
).bindparams(sa.bindparam("codes", expanding=True)),
|
||||||
|
{"role_name": "Partner", "codes": PARTNER_REMOVE_PERMISSIONS},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT r.id AS role_id, p.id AS permission_id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.code IN :codes
|
||||||
|
WHERE r.name = :role_name
|
||||||
|
"""
|
||||||
|
).bindparams(sa.bindparam("codes", expanding=True)),
|
||||||
|
{"role_name": "Partner", "codes": PARTNER_REMOVE_PERMISSIONS},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
exists = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM role_permissions
|
||||||
|
WHERE role_id = :role_id AND permission_id = :permission_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"role_id": row.role_id, "permission_id": row.permission_id},
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
VALUES (:role_id, :permission_id)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"role_id": row.role_id, "permission_id": row.permission_id},
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""client service subscriptions and lightweight assignment
|
||||||
|
|
||||||
|
Revision ID: 20260425_client_service_subscriptions
|
||||||
|
Revises: 20260424_partner_service_restrictions
|
||||||
|
Create Date: 2026-04-25 20:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260425_client_service_subscriptions"
|
||||||
|
down_revision = "20260424_partner_service_restrictions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name: str) -> bool:
|
||||||
|
return name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not _has_table("client_service_subscriptions"):
|
||||||
|
op.create_table(
|
||||||
|
"client_service_subscriptions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("firm_service_selection_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_manager_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_staff_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["firm_service_selection_id"], ["firm_service_selections.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_manager_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_staff_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "client_id", "service_catalogue_id", name="uq_client_service_subscription_tenant_client_service"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_client_id", ["client_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_service_catalogue_id", ["service_catalogue_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_firm_service_selection_id", ["firm_service_selection_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_partner_user_id", ["assigned_partner_user_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_manager_user_id", ["assigned_manager_user_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_staff_user_id", ["assigned_staff_user_id"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_status", ["status"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_is_active", ["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if _has_table("client_service_subscriptions"):
|
||||||
|
op.drop_table("client_service_subscriptions")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""S4.3/S4.4 service execution task instances
|
||||||
|
|
||||||
|
Revision ID: 20260426_service_execution_tasks
|
||||||
|
Revises: 20260425_client_service_subscriptions
|
||||||
|
Create Date: 2026-04-26 20:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260426_service_execution_tasks"
|
||||||
|
down_revision = "20260425_client_service_subscriptions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(name: str) -> bool:
|
||||||
|
return name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False):
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not _has_table("client_service_task_instances"):
|
||||||
|
op.create_table(
|
||||||
|
"client_service_task_instances",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("subscription_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("firm_task_template_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("task_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sequence_no", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("default_role_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("assigned_to_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("started_at_utc", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["subscription_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_to_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("subscription_id", "firm_task_template_id", name="uq_client_service_task_subscription_template"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_subscription_id", ["subscription_id"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_client_id", ["client_id"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_service_catalogue_id", ["service_catalogue_id"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_assigned_to_user_id", ["assigned_to_user_id"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_status", ["status"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_is_active", ["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if _has_table("client_service_task_instances"):
|
||||||
|
op.drop_table("client_service_task_instances")
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""engagement year columns and locking
|
||||||
|
|
||||||
|
Revision ID: 20260429_engagement_year_locking
|
||||||
|
Revises: 20260426_service_execution_tasks
|
||||||
|
Create Date: 2026-04-29
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260429_engagement_year_locking"
|
||||||
|
down_revision: Union[str, None] = "20260426_service_execution_tasks"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(c["name"] == column_name for c in insp.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_column("client_service_subscriptions", "financial_year"):
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("financial_year", sa.String(length=9), nullable=False, server_default="2025-26"))
|
||||||
|
if not _has_column("client_service_subscriptions", "assessment_year"):
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("assessment_year", sa.String(length=9), nullable=True))
|
||||||
|
if not _has_column("client_service_subscriptions", "is_locked"):
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
if not _has_column("client_service_subscriptions", "locked_at_utc"):
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
if not _has_column("client_service_subscriptions", "locked_by_user_id"):
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("locked_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_financial_year", ["financial_year"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assessment_year", ["assessment_year"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_is_locked", ["is_locked"])
|
||||||
|
|
||||||
|
if not _has_column("client_service_task_instances", "financial_year"):
|
||||||
|
op.add_column("client_service_task_instances", sa.Column("financial_year", sa.String(length=9), nullable=False, server_default="2025-26"))
|
||||||
|
if not _has_column("client_service_task_instances", "assessment_year"):
|
||||||
|
op.add_column("client_service_task_instances", sa.Column("assessment_year", sa.String(length=9), nullable=True))
|
||||||
|
if not _has_column("client_service_task_instances", "is_locked"):
|
||||||
|
op.add_column("client_service_task_instances", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
if not _has_column("client_service_task_instances", "locked_at_utc"):
|
||||||
|
op.add_column("client_service_task_instances", sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
if not _has_column("client_service_task_instances", "locked_by_user_id"):
|
||||||
|
op.add_column("client_service_task_instances", sa.Column("locked_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_financial_year", ["financial_year"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_assessment_year", ["assessment_year"])
|
||||||
|
_create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_is_locked", ["is_locked"])
|
||||||
|
|
||||||
|
# SQLite/PostgreSQL compatible replacement of uniqueness to include financial_year.
|
||||||
|
with op.batch_alter_table("client_service_subscriptions") as batch_op:
|
||||||
|
try:
|
||||||
|
batch_op.drop_constraint("uq_client_service_subscription_tenant_client_service", type_="unique")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_client_service_subscription_tenant_client_service_year",
|
||||||
|
["tenant_id", "client_id", "service_catalogue_id", "financial_year"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with op.batch_alter_table("client_service_task_instances") as batch_op:
|
||||||
|
try:
|
||||||
|
batch_op.drop_constraint("uq_client_service_task_subscription_template", type_="unique")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_client_service_task_subscription_template_year",
|
||||||
|
["subscription_id", "firm_task_template_id", "financial_year"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""add engagement type to services and engagements
|
||||||
|
|
||||||
|
Revision ID: 20260501_services_engagement_type
|
||||||
|
Revises: 20260429_engagement_year_locking
|
||||||
|
Create Date: 2026-05-01
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260501_services_engagement_type"
|
||||||
|
down_revision: Union[str, None] = "20260429_engagement_year_locking"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(c["name"] == column_name for c in insp.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_column("service_catalogues", "engagement_type"):
|
||||||
|
op.add_column(
|
||||||
|
"service_catalogues",
|
||||||
|
sa.Column("engagement_type", sa.String(length=20), nullable=False, server_default="non_audit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _has_column("client_service_subscriptions", "engagement_type"):
|
||||||
|
op.add_column(
|
||||||
|
"client_service_subscriptions",
|
||||||
|
sa.Column("engagement_type", sa.String(length=20), nullable=False, server_default="non_audit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill existing engagement records from their service catalogue classification.
|
||||||
|
bind = op.get_bind()
|
||||||
|
bind.execute(sa.text("""
|
||||||
|
UPDATE client_service_subscriptions
|
||||||
|
SET engagement_type = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT service_catalogues.engagement_type
|
||||||
|
FROM service_catalogues
|
||||||
|
WHERE service_catalogues.id = client_service_subscriptions.service_catalogue_id
|
||||||
|
),
|
||||||
|
'non_audit'
|
||||||
|
)
|
||||||
|
WHERE engagement_type IS NULL OR engagement_type = ''
|
||||||
|
"""))
|
||||||
|
|
||||||
|
_create_index_if_missing("service_catalogues", "ix_service_catalogues_engagement_type", ["engagement_type"])
|
||||||
|
_create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_engagement_type", ["engagement_type"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_index("client_service_subscriptions", "ix_client_service_subscriptions_engagement_type"):
|
||||||
|
op.drop_index("ix_client_service_subscriptions_engagement_type", table_name="client_service_subscriptions")
|
||||||
|
if _has_index("service_catalogues", "ix_service_catalogues_engagement_type"):
|
||||||
|
op.drop_index("ix_service_catalogues_engagement_type", table_name="service_catalogues")
|
||||||
|
if _has_column("client_service_subscriptions", "engagement_type"):
|
||||||
|
op.drop_column("client_service_subscriptions", "engagement_type")
|
||||||
|
if _has_column("service_catalogues", "engagement_type"):
|
||||||
|
op.drop_column("service_catalogues", "engagement_type")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""tenant firm type and engagement review partner
|
||||||
|
|
||||||
|
Revision ID: 20260502_tenant_firm_type_review_partner
|
||||||
|
Revises: 20260501_services_engagement_type
|
||||||
|
Create Date: 2026-05-02
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260502_tenant_firm_type_review_partner"
|
||||||
|
down_revision: Union[str, None] = "20260501_services_engagement_type"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(c["name"] == column_name for c in insp.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_column("tenants", "firm_type"):
|
||||||
|
op.add_column(
|
||||||
|
"tenants",
|
||||||
|
sa.Column("firm_type", sa.String(length=30), nullable=False, server_default="proprietorship"),
|
||||||
|
)
|
||||||
|
_create_index_if_missing("tenants", "ix_tenants_firm_type", ["firm_type"])
|
||||||
|
|
||||||
|
if not _has_column("client_service_subscriptions", "review_partner_user_id"):
|
||||||
|
op.add_column(
|
||||||
|
"client_service_subscriptions",
|
||||||
|
sa.Column("review_partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
_create_index_if_missing(
|
||||||
|
"client_service_subscriptions",
|
||||||
|
"ix_client_service_subscriptions_review_partner_user_id",
|
||||||
|
["review_partner_user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_index("client_service_subscriptions", "ix_client_service_subscriptions_review_partner_user_id"):
|
||||||
|
op.drop_index("ix_client_service_subscriptions_review_partner_user_id", table_name="client_service_subscriptions")
|
||||||
|
if _has_column("client_service_subscriptions", "review_partner_user_id"):
|
||||||
|
op.drop_column("client_service_subscriptions", "review_partner_user_id")
|
||||||
|
|
||||||
|
if _has_index("tenants", "ix_tenants_firm_type"):
|
||||||
|
op.drop_index("ix_tenants_firm_type", table_name="tenants")
|
||||||
|
if _has_column("tenants", "firm_type"):
|
||||||
|
op.drop_column("tenants", "firm_type")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""rename audit classification to assurance and add client-wise review partner
|
||||||
|
|
||||||
|
Revision ID: 20260503_assurance_review_partner
|
||||||
|
Revises: 20260502_tenant_firm_type_review_partner
|
||||||
|
Create Date: 2026-05-03
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260503_assurance_review_partner"
|
||||||
|
down_revision: Union[str, None] = "20260502_tenant_firm_type_review_partner"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(c["name"] == column_name for c in insp.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# Client master: optional default review partner used for partnership + assurance engagements.
|
||||||
|
if not _has_column("clients", "default_review_partner_user_id"):
|
||||||
|
op.add_column("clients", sa.Column("default_review_partner_user_id", sa.Integer(), nullable=True))
|
||||||
|
_create_index_if_missing(
|
||||||
|
"clients",
|
||||||
|
"ix_clients_default_review_partner_user_id",
|
||||||
|
["default_review_partner_user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Safety only: normally this column is created by 20260502_tenant_firm_type_review_partner.
|
||||||
|
if not _has_column("client_service_subscriptions", "review_partner_user_id"):
|
||||||
|
op.add_column(
|
||||||
|
"client_service_subscriptions",
|
||||||
|
sa.Column("review_partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
_create_index_if_missing(
|
||||||
|
"client_service_subscriptions",
|
||||||
|
"ix_client_service_subscriptions_review_partner_user_id",
|
||||||
|
["review_partner_user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert old audit / non-audit values to final assurance / non-assurance values.
|
||||||
|
bind.execute(sa.text("""
|
||||||
|
UPDATE service_catalogues
|
||||||
|
SET engagement_type = CASE
|
||||||
|
WHEN LOWER(COALESCE(engagement_type, '')) IN ('audit', 'aud', 'assurance') THEN 'assurance'
|
||||||
|
ELSE 'non_assurance'
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
bind.execute(sa.text("""
|
||||||
|
UPDATE client_service_subscriptions
|
||||||
|
SET engagement_type = CASE
|
||||||
|
WHEN LOWER(COALESCE(engagement_type, '')) IN ('audit', 'aud', 'assurance') THEN 'assurance'
|
||||||
|
ELSE 'non_assurance'
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Backfill review partner snapshots only for partnership-firm assurance engagements.
|
||||||
|
# Uses tenants.firm_type created by 20260502, not a separate firm_type column.
|
||||||
|
bind.execute(sa.text("""
|
||||||
|
UPDATE client_service_subscriptions
|
||||||
|
SET review_partner_user_id = (
|
||||||
|
SELECT clients.default_review_partner_user_id
|
||||||
|
FROM clients
|
||||||
|
WHERE clients.id = client_service_subscriptions.client_id
|
||||||
|
)
|
||||||
|
WHERE engagement_type = 'assurance'
|
||||||
|
AND review_partner_user_id IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM tenants
|
||||||
|
WHERE tenants.id = client_service_subscriptions.tenant_id
|
||||||
|
AND COALESCE(tenants.firm_type, 'proprietorship') = 'partnership'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
bind.execute(sa.text("""
|
||||||
|
UPDATE service_catalogues
|
||||||
|
SET engagement_type = CASE
|
||||||
|
WHEN engagement_type = 'assurance' THEN 'audit'
|
||||||
|
ELSE 'non_audit'
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
bind.execute(sa.text("""
|
||||||
|
UPDATE client_service_subscriptions
|
||||||
|
SET engagement_type = CASE
|
||||||
|
WHEN engagement_type = 'assurance' THEN 'audit'
|
||||||
|
ELSE 'non_audit'
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Do not drop client_service_subscriptions.review_partner_user_id here.
|
||||||
|
# That column belongs to the previous 20260502 migration.
|
||||||
|
if _has_index("clients", "ix_clients_default_review_partner_user_id"):
|
||||||
|
op.drop_index("ix_clients_default_review_partner_user_id", table_name="clients")
|
||||||
|
if _has_column("clients", "default_review_partner_user_id"):
|
||||||
|
op.drop_column("clients", "default_review_partner_user_id")
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""services phase 4A due date engine foundation
|
||||||
|
|
||||||
|
Revision ID: 20260504_due_date_engine_foundation
|
||||||
|
Revises: 20260503_assurance_review_partner
|
||||||
|
Create Date: 2026-05-04
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260504_due_date_engine_foundation"
|
||||||
|
down_revision: Union[str, None] = "20260503_assurance_review_partner"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _insp():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in _insp().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(c["name"] == column_name for c in _insp().get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
if _has_table(table_name) and not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("service_due_date_rules"):
|
||||||
|
op.create_table(
|
||||||
|
"service_due_date_rules",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rule_name", sa.String(length=150), nullable=False),
|
||||||
|
sa.Column("period_type", sa.String(length=30), nullable=False, server_default="yearly"),
|
||||||
|
sa.Column("due_year_basis", sa.String(length=40), nullable=False, server_default="assessment_year_start"),
|
||||||
|
sa.Column("due_day", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("due_month", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("due_month_offset", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("days_offset_after_event", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("service_catalogue_id", "rule_name", name="uq_service_due_date_rules_catalogue_name"),
|
||||||
|
)
|
||||||
|
_create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_service_catalogue_id", ["service_catalogue_id"])
|
||||||
|
_create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_period_type", ["period_type"])
|
||||||
|
_create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_is_active", ["is_active"])
|
||||||
|
|
||||||
|
if not _has_table("service_due_date_extensions"):
|
||||||
|
op.create_table(
|
||||||
|
"service_due_date_extensions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("due_date_rule_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("financial_year", sa.String(length=9), nullable=False),
|
||||||
|
sa.Column("assessment_year", sa.String(length=9), nullable=True),
|
||||||
|
sa.Column("period_label", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("previous_due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("extended_due_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("extension_sequence", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("notification_reference", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("notification_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["due_date_rule_id"], ["service_due_date_rules.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
)
|
||||||
|
for index_name, columns in [
|
||||||
|
("ix_service_due_date_extensions_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_service_due_date_extensions_service_catalogue_id", ["service_catalogue_id"]),
|
||||||
|
("ix_service_due_date_extensions_due_date_rule_id", ["due_date_rule_id"]),
|
||||||
|
("ix_service_due_date_extensions_financial_year", ["financial_year"]),
|
||||||
|
("ix_service_due_date_extensions_assessment_year", ["assessment_year"]),
|
||||||
|
("ix_service_due_date_extensions_period_label", ["period_label"]),
|
||||||
|
("ix_service_due_date_extensions_extended_due_date", ["extended_due_date"]),
|
||||||
|
]:
|
||||||
|
_create_index_if_missing("service_due_date_extensions", index_name, columns)
|
||||||
|
|
||||||
|
subscription_columns = [
|
||||||
|
("due_date_rule_id", sa.Column("due_date_rule_id", sa.Integer(), nullable=True)),
|
||||||
|
("original_due_date", sa.Column("original_due_date", sa.Date(), nullable=True)),
|
||||||
|
("current_due_date", sa.Column("current_due_date", sa.Date(), nullable=True)),
|
||||||
|
("due_date_source", sa.Column("due_date_source", sa.String(length=30), nullable=True)),
|
||||||
|
]
|
||||||
|
for column_name, column in subscription_columns:
|
||||||
|
if not _has_column("client_service_subscriptions", column_name):
|
||||||
|
op.add_column("client_service_subscriptions", column)
|
||||||
|
|
||||||
|
for index_name, columns in [
|
||||||
|
("ix_client_service_subscriptions_due_date_rule_id", ["due_date_rule_id"]),
|
||||||
|
("ix_client_service_subscriptions_original_due_date", ["original_due_date"]),
|
||||||
|
("ix_client_service_subscriptions_current_due_date", ["current_due_date"]),
|
||||||
|
]:
|
||||||
|
_create_index_if_missing("client_service_subscriptions", index_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for index_name in [
|
||||||
|
"ix_client_service_subscriptions_current_due_date",
|
||||||
|
"ix_client_service_subscriptions_original_due_date",
|
||||||
|
"ix_client_service_subscriptions_due_date_rule_id",
|
||||||
|
]:
|
||||||
|
if _has_index("client_service_subscriptions", index_name):
|
||||||
|
op.drop_index(index_name, table_name="client_service_subscriptions")
|
||||||
|
for column_name in ["due_date_source", "current_due_date", "original_due_date", "due_date_rule_id"]:
|
||||||
|
if _has_column("client_service_subscriptions", column_name):
|
||||||
|
op.drop_column("client_service_subscriptions", column_name)
|
||||||
|
if _has_table("service_due_date_extensions"):
|
||||||
|
op.drop_table("service_due_date_extensions")
|
||||||
|
if _has_table("service_due_date_rules"):
|
||||||
|
op.drop_table("service_due_date_rules")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""services phase 4A renewal before expiry due dates
|
||||||
|
|
||||||
|
Revision ID: 20260505_renewal_before_expiry_due_dates
|
||||||
|
Revises: 20260504_due_date_engine_foundation
|
||||||
|
Create Date: 2026-05-05
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260505_renewal_before_expiry_due_dates"
|
||||||
|
down_revision: Union[str, None] = "20260504_due_date_engine_foundation"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _insp():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in _insp().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(c["name"] == column_name for c in _insp().get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _has_table("service_due_date_rules") and not _has_column("service_due_date_rules", "renewal_days_before_expiry"):
|
||||||
|
op.add_column("service_due_date_rules", sa.Column("renewal_days_before_expiry", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
if _has_table("client_service_subscriptions") and not _has_column("client_service_subscriptions", "expiry_date"):
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("expiry_date", sa.Date(), nullable=True))
|
||||||
|
|
||||||
|
if _has_table("client_service_subscriptions") and not _has_index("client_service_subscriptions", "ix_client_service_subscriptions_expiry_date"):
|
||||||
|
op.create_index("ix_client_service_subscriptions_expiry_date", "client_service_subscriptions", ["expiry_date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("client_service_subscriptions") and _has_index("client_service_subscriptions", "ix_client_service_subscriptions_expiry_date"):
|
||||||
|
op.drop_index("ix_client_service_subscriptions_expiry_date", table_name="client_service_subscriptions")
|
||||||
|
if _has_table("client_service_subscriptions") and _has_column("client_service_subscriptions", "expiry_date"):
|
||||||
|
op.drop_column("client_service_subscriptions", "expiry_date")
|
||||||
|
if _has_table("service_due_date_rules") and _has_column("service_due_date_rules", "renewal_days_before_expiry"):
|
||||||
|
op.drop_column("service_due_date_rules", "renewal_days_before_expiry")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Work tracker Phase 4B internal target dates
|
||||||
|
|
||||||
|
Revision ID: 20260506_work_tracker_phase_4b
|
||||||
|
Revises: 20260505_renewal_before_expiry_due_dates
|
||||||
|
Create Date: 2026-05-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260506_work_tracker_phase_4b"
|
||||||
|
down_revision = "20260505_renewal_before_expiry_due_dates"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
try:
|
||||||
|
return {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _index_names(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
try:
|
||||||
|
return {index["name"] for index in inspector.get_indexes(table_name) if index.get("name")}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
columns = _table_columns("client_service_task_instances")
|
||||||
|
if "internal_target_date" not in columns:
|
||||||
|
op.add_column("client_service_task_instances", sa.Column("internal_target_date", sa.Date(), nullable=True))
|
||||||
|
|
||||||
|
indexes = _index_names("client_service_task_instances")
|
||||||
|
if "ix_client_service_task_instances_internal_target_date" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_client_service_task_instances_internal_target_date",
|
||||||
|
"client_service_task_instances",
|
||||||
|
["internal_target_date"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
indexes = _index_names("client_service_task_instances")
|
||||||
|
if "ix_client_service_task_instances_internal_target_date" in indexes:
|
||||||
|
op.drop_index("ix_client_service_task_instances_internal_target_date", table_name="client_service_task_instances")
|
||||||
|
|
||||||
|
columns = _table_columns("client_service_task_instances")
|
||||||
|
if "internal_target_date" in columns:
|
||||||
|
op.drop_column("client_service_task_instances", "internal_target_date")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""services phase 4C task communication timeline
|
||||||
|
|
||||||
|
Revision ID: 20260507_task_communication_phase_4c
|
||||||
|
Revises: 20260506_work_tracker_phase_4b
|
||||||
|
Create Date: 2026-05-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260507_task_communication_phase_4c"
|
||||||
|
down_revision = "20260506_work_tracker_phase_4b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _insp():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in _insp().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("service_task_comments"):
|
||||||
|
op.create_table(
|
||||||
|
"service_task_comments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True),
|
||||||
|
sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("task_instance_id", sa.Integer(), sa.ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("comment_type", sa.String(length=40), nullable=False, server_default="internal_note"),
|
||||||
|
sa.Column("visibility", sa.String(length=30), nullable=False, server_default="internal"),
|
||||||
|
sa.Column("message", sa.Text(), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
)
|
||||||
|
|
||||||
|
index_specs = [
|
||||||
|
("ix_service_task_comments_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_service_task_comments_branch_id", ["branch_id"]),
|
||||||
|
("ix_service_task_comments_subscription_id", ["subscription_id"]),
|
||||||
|
("ix_service_task_comments_task_instance_id", ["task_instance_id"]),
|
||||||
|
("ix_service_task_comments_comment_type", ["comment_type"]),
|
||||||
|
("ix_service_task_comments_visibility", ["visibility"]),
|
||||||
|
("ix_service_task_comments_created_by_user_id", ["created_by_user_id"]),
|
||||||
|
("ix_service_task_comments_is_deleted", ["is_deleted"]),
|
||||||
|
]
|
||||||
|
for index_name, columns in index_specs:
|
||||||
|
if not _has_index("service_task_comments", index_name):
|
||||||
|
op.create_index(index_name, "service_task_comments", columns, unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("service_task_comments"):
|
||||||
|
op.drop_table("service_task_comments")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Phase 5A.1 consultant portal foundation
|
||||||
|
|
||||||
|
Revision ID: 20260508_consultant_portal_phase_5a1
|
||||||
|
Revises: 20260507_task_communication_phase_4c
|
||||||
|
Create Date: 2026-05-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260508_consultant_portal_phase_5a1"
|
||||||
|
down_revision: Union[str, None] = "20260507_task_communication_phase_4c"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("consultant_profiles"):
|
||||||
|
op.create_table(
|
||||||
|
"consultant_profiles",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("consultant_type", sa.String(length=50), nullable=False, server_default="external_consultant"),
|
||||||
|
sa.Column("firm_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("contact_person", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("specialisation", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("gstin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("pan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("address", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("onboarding_status", sa.String(length=30), nullable=False, server_default="approved"),
|
||||||
|
sa.Column("is_platform_partner", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_franchise_partner", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_saas_customer", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_consultant_profiles_tenant_id", "consultant_profiles", ["tenant_id"])
|
||||||
|
op.create_index("ix_consultant_profiles_branch_id", "consultant_profiles", ["branch_id"])
|
||||||
|
op.create_index("ix_consultant_profiles_user_id", "consultant_profiles", ["user_id"])
|
||||||
|
op.create_index("ix_consultant_profiles_email", "consultant_profiles", ["email"])
|
||||||
|
op.create_index("ix_consultant_profiles_status", "consultant_profiles", ["status"])
|
||||||
|
op.create_index("ix_consultant_profiles_onboarding_status", "consultant_profiles", ["onboarding_status"])
|
||||||
|
op.create_index("ix_consultant_profiles_is_active", "consultant_profiles", ["is_active"])
|
||||||
|
op.create_index("ix_consultant_profiles_consultant_type", "consultant_profiles", ["consultant_type"])
|
||||||
|
|
||||||
|
if not _has_table("client_consultant_links"):
|
||||||
|
op.create_table(
|
||||||
|
"client_consultant_links",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("relationship_type", sa.String(length=50), nullable=False, server_default="accounts_consultant"),
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("can_view_client", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("can_view_services", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("can_view_due_dates", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("can_view_communications", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_client_consultant_links_tenant_id", "client_consultant_links", ["tenant_id"])
|
||||||
|
op.create_index("ix_client_consultant_links_branch_id", "client_consultant_links", ["branch_id"])
|
||||||
|
op.create_index("ix_client_consultant_links_client_id", "client_consultant_links", ["client_id"])
|
||||||
|
op.create_index("ix_client_consultant_links_consultant_id", "client_consultant_links", ["consultant_id"])
|
||||||
|
op.create_index("ix_client_consultant_links_service_catalogue_id", "client_consultant_links", ["service_catalogue_id"])
|
||||||
|
op.create_index("ix_client_consultant_links_relationship_type", "client_consultant_links", ["relationship_type"])
|
||||||
|
op.create_index("ix_client_consultant_links_is_active", "client_consultant_links", ["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("client_consultant_links"):
|
||||||
|
op.drop_index("ix_client_consultant_links_is_active", table_name="client_consultant_links")
|
||||||
|
op.drop_index("ix_client_consultant_links_relationship_type", table_name="client_consultant_links")
|
||||||
|
op.drop_index("ix_client_consultant_links_service_catalogue_id", table_name="client_consultant_links")
|
||||||
|
op.drop_index("ix_client_consultant_links_consultant_id", table_name="client_consultant_links")
|
||||||
|
op.drop_index("ix_client_consultant_links_client_id", table_name="client_consultant_links")
|
||||||
|
op.drop_index("ix_client_consultant_links_branch_id", table_name="client_consultant_links")
|
||||||
|
op.drop_index("ix_client_consultant_links_tenant_id", table_name="client_consultant_links")
|
||||||
|
op.drop_table("client_consultant_links")
|
||||||
|
if _has_table("consultant_profiles"):
|
||||||
|
op.drop_index("ix_consultant_profiles_consultant_type", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_is_active", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_onboarding_status", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_status", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_email", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_user_id", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_branch_id", table_name="consultant_profiles")
|
||||||
|
op.drop_index("ix_consultant_profiles_tenant_id", table_name="consultant_profiles")
|
||||||
|
op.drop_table("consultant_profiles")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Phase 5A.2 consultant-managed clients
|
||||||
|
|
||||||
|
Revision ID: 20260509_consultant_managed_clients_phase_5a2
|
||||||
|
Revises: 20260508_consultant_portal_phase_5a1
|
||||||
|
Create Date: 2026-05-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260509_consultant_managed_clients_phase_5a2"
|
||||||
|
down_revision: Union[str, None] = "20260508_consultant_portal_phase_5a1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return any(ix.get("name") == index_name for ix in inspector.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("consultant_managed_clients"):
|
||||||
|
op.create_table(
|
||||||
|
"consultant_managed_clients",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("linked_firm_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("client_code", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("client_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("trade_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("client_type", sa.String(length=100), nullable=False, server_default="Other"),
|
||||||
|
sa.Column("pan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("gstin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("tan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("contact_person_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("address_line_1", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("address_line_2", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("city", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("state", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("pincode", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("country", sa.String(length=100), nullable=True, server_default="India"),
|
||||||
|
sa.Column("service_interest", sa.Text(), nullable=True),
|
||||||
|
sa.Column("relationship_stage", sa.String(length=30), nullable=False, server_default="managed"),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_consultant_managed_clients_tenant_id", "consultant_managed_clients", ["tenant_id"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_branch_id", "consultant_managed_clients", ["branch_id"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_consultant_id", "consultant_managed_clients", ["consultant_id"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_linked_firm_client_id", "consultant_managed_clients", ["linked_firm_client_id"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_client_code", "consultant_managed_clients", ["client_code"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_client_name", "consultant_managed_clients", ["client_name"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_pan", "consultant_managed_clients", ["pan"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_gstin", "consultant_managed_clients", ["gstin"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_email", "consultant_managed_clients", ["email"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_relationship_stage", "consultant_managed_clients", ["relationship_stage"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_status", "consultant_managed_clients", ["status"])
|
||||||
|
op.create_index("ix_consultant_managed_clients_is_active", "consultant_managed_clients", ["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("consultant_managed_clients"):
|
||||||
|
for index_name in [
|
||||||
|
"ix_consultant_managed_clients_is_active",
|
||||||
|
"ix_consultant_managed_clients_status",
|
||||||
|
"ix_consultant_managed_clients_relationship_stage",
|
||||||
|
"ix_consultant_managed_clients_email",
|
||||||
|
"ix_consultant_managed_clients_gstin",
|
||||||
|
"ix_consultant_managed_clients_pan",
|
||||||
|
"ix_consultant_managed_clients_client_name",
|
||||||
|
"ix_consultant_managed_clients_client_code",
|
||||||
|
"ix_consultant_managed_clients_linked_firm_client_id",
|
||||||
|
"ix_consultant_managed_clients_consultant_id",
|
||||||
|
"ix_consultant_managed_clients_branch_id",
|
||||||
|
"ix_consultant_managed_clients_tenant_id",
|
||||||
|
]:
|
||||||
|
if _has_index("consultant_managed_clients", index_name):
|
||||||
|
op.drop_index(index_name, table_name="consultant_managed_clients")
|
||||||
|
op.drop_table("consultant_managed_clients")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""consultant workspace foundation phase 5a3
|
||||||
|
|
||||||
|
Revision ID: 20260510_consultant_workspace_phase_5a3
|
||||||
|
Revises: 20260509_consultant_managed_clients_phase_5a2
|
||||||
|
Create Date: 2026-05-10 00:00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260510_consultant_workspace_phase_5a3"
|
||||||
|
down_revision = "20260509_consultant_managed_clients_phase_5a2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "consultant_workspaces"):
|
||||||
|
op.create_table(
|
||||||
|
"consultant_workspaces",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("workspace_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("workspace_type", sa.String(length=50), nullable=False, server_default="consultant_saas"),
|
||||||
|
sa.Column("plan_code", sa.String(length=50), nullable=False, server_default="starter"),
|
||||||
|
sa.Column("billing_cycle", sa.String(length=30), nullable=False, server_default="manual"),
|
||||||
|
sa.Column("subscription_status", sa.String(length=30), nullable=False, server_default="trial"),
|
||||||
|
sa.Column("subscription_start_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("subscription_end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("max_managed_clients", sa.Integer(), nullable=False, server_default="25"),
|
||||||
|
sa.Column("max_user_accounts", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("allow_client_portal", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("allow_firm_referrals", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("allow_service_marketplace", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_consultant_workspaces_tenant_id", "consultant_workspaces", ["tenant_id"])
|
||||||
|
op.create_index("ix_consultant_workspaces_branch_id", "consultant_workspaces", ["branch_id"])
|
||||||
|
op.create_index("ix_consultant_workspaces_consultant_id", "consultant_workspaces", ["consultant_id"])
|
||||||
|
op.create_index("ix_consultant_workspaces_workspace_code", "consultant_workspaces", ["workspace_code"])
|
||||||
|
op.create_index("ix_consultant_workspaces_workspace_type", "consultant_workspaces", ["workspace_type"])
|
||||||
|
op.create_index("ix_consultant_workspaces_plan_code", "consultant_workspaces", ["plan_code"])
|
||||||
|
op.create_index("ix_consultant_workspaces_subscription_status", "consultant_workspaces", ["subscription_status"])
|
||||||
|
op.create_index("ix_consultant_workspaces_billing_cycle", "consultant_workspaces", ["billing_cycle"])
|
||||||
|
op.create_index("ix_consultant_workspaces_is_active", "consultant_workspaces", ["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, "consultant_workspaces"):
|
||||||
|
op.drop_index("ix_consultant_workspaces_is_active", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_billing_cycle", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_subscription_status", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_plan_code", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_workspace_type", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_workspace_code", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_consultant_id", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_branch_id", table_name="consultant_workspaces")
|
||||||
|
op.drop_index("ix_consultant_workspaces_tenant_id", table_name="consultant_workspaces")
|
||||||
|
op.drop_table("consultant_workspaces")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""consultant communications and service requests phase 5a5 5a6
|
||||||
|
|
||||||
|
Revision ID: 20260511_consultant_completion_phase_5a5_5a6
|
||||||
|
Revises: 20260510_consultant_workspace_phase_5a3
|
||||||
|
Create Date: 2026-05-11
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260511_consultant_completion_phase_5a5_5a6"
|
||||||
|
down_revision = "20260510_consultant_workspace_phase_5a3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(bind, table_name):
|
||||||
|
return False
|
||||||
|
return column_name in {col["name"] for col in sa.inspect(bind).get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _has_table(bind, "consultant_service_requests"):
|
||||||
|
op.create_table(
|
||||||
|
"consultant_service_requests",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("managed_client_id", sa.Integer(), sa.ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("firm_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("request_no", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("request_type", sa.String(length=50), nullable=False, server_default="service_request"),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False, server_default="submitted"),
|
||||||
|
sa.Column("priority", sa.String(length=30), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("requested_service_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("requested_due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("subject", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("consultant_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("firm_response", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
for index_name, columns in {
|
||||||
|
"ix_consultant_service_requests_tenant_id": ["tenant_id"],
|
||||||
|
"ix_consultant_service_requests_branch_id": ["branch_id"],
|
||||||
|
"ix_consultant_service_requests_consultant_id": ["consultant_id"],
|
||||||
|
"ix_consultant_service_requests_managed_client_id": ["managed_client_id"],
|
||||||
|
"ix_consultant_service_requests_firm_client_id": ["firm_client_id"],
|
||||||
|
"ix_consultant_service_requests_service_catalogue_id": ["service_catalogue_id"],
|
||||||
|
"ix_consultant_service_requests_request_no": ["request_no"],
|
||||||
|
"ix_consultant_service_requests_status": ["status"],
|
||||||
|
"ix_consultant_service_requests_priority": ["priority"],
|
||||||
|
"ix_consultant_service_requests_is_active": ["is_active"],
|
||||||
|
}.items():
|
||||||
|
try:
|
||||||
|
op.create_index(index_name, "consultant_service_requests", columns)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _has_table(bind, "service_catalogues") and not _has_column(bind, "service_catalogues", "is_consultant_requestable"):
|
||||||
|
with op.batch_alter_table("service_catalogues") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False, server_default=sa.text("0")))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _has_table(bind, "consultant_service_requests"):
|
||||||
|
op.drop_table("consultant_service_requests")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""consultant conversion and workspace limits phase 5a7 5a8
|
||||||
|
|
||||||
|
Revision ID: 20260512_consultant_conversion_limits_phase_5a7_5a8
|
||||||
|
Revises: 20260511_consultant_completion_phase_5a5_5a6
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260512_consultant_conversion_limits_phase_5a7_5a8"
|
||||||
|
down_revision = "20260511_consultant_completion_phase_5a5_5a6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return column_name in {col["name"] for col in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return index_name in {idx["name"] for idx in inspector.get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
table = "consultant_managed_clients"
|
||||||
|
if not _has_column(table, "conversion_status"):
|
||||||
|
op.add_column(table, sa.Column("conversion_status", sa.String(length=30), nullable=False, server_default="not_requested"))
|
||||||
|
if not _has_column(table, "conversion_requested_at_utc"):
|
||||||
|
op.add_column(table, sa.Column("conversion_requested_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
if not _has_column(table, "conversion_requested_by_user_id"):
|
||||||
|
op.add_column(table, sa.Column("conversion_requested_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
if not _has_column(table, "conversion_reviewed_at_utc"):
|
||||||
|
op.add_column(table, sa.Column("conversion_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
if not _has_column(table, "conversion_reviewed_by_user_id"):
|
||||||
|
op.add_column(table, sa.Column("conversion_reviewed_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
if not _has_column(table, "conversion_notes"):
|
||||||
|
op.add_column(table, sa.Column("conversion_notes", sa.Text(), nullable=True))
|
||||||
|
if not _has_column(table, "conversion_firm_notes"):
|
||||||
|
op.add_column(table, sa.Column("conversion_firm_notes", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
if not _has_index(table, "ix_consultant_managed_clients_conversion_status"):
|
||||||
|
op.create_index("ix_consultant_managed_clients_conversion_status", table, ["conversion_status"])
|
||||||
|
if not _has_index(table, "ix_consultant_managed_clients_conversion_requested_by_user_id"):
|
||||||
|
op.create_index("ix_consultant_managed_clients_conversion_requested_by_user_id", table, ["conversion_requested_by_user_id"])
|
||||||
|
if not _has_index(table, "ix_consultant_managed_clients_conversion_reviewed_by_user_id"):
|
||||||
|
op.create_index("ix_consultant_managed_clients_conversion_reviewed_by_user_id", table, ["conversion_reviewed_by_user_id"])
|
||||||
|
|
||||||
|
# SQLite supports ALTER TABLE ADD COLUMN but does not always enforce server default cleanup safely.
|
||||||
|
# Keeping the server default is intentional so existing local installs remain simple and stable.
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
table = "consultant_managed_clients"
|
||||||
|
for index_name in [
|
||||||
|
"ix_consultant_managed_clients_conversion_reviewed_by_user_id",
|
||||||
|
"ix_consultant_managed_clients_conversion_requested_by_user_id",
|
||||||
|
"ix_consultant_managed_clients_conversion_status",
|
||||||
|
]:
|
||||||
|
if _has_index(table, index_name):
|
||||||
|
op.drop_index(index_name, table_name=table)
|
||||||
|
|
||||||
|
for column_name in [
|
||||||
|
"conversion_firm_notes",
|
||||||
|
"conversion_notes",
|
||||||
|
"conversion_reviewed_by_user_id",
|
||||||
|
"conversion_reviewed_at_utc",
|
||||||
|
"conversion_requested_by_user_id",
|
||||||
|
"conversion_requested_at_utc",
|
||||||
|
"conversion_status",
|
||||||
|
]:
|
||||||
|
if _has_column(table, column_name):
|
||||||
|
op.drop_column(table, column_name)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Phase 6A employee core foundation
|
||||||
|
|
||||||
|
Revision ID: 20260513_phase_6a_employees_core
|
||||||
|
Revises: 20260512_consultant_conversion_limits_phase_5a7_5a8
|
||||||
|
Create Date: 2026-05-05
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260513_phase_6a_employees_core"
|
||||||
|
down_revision = "20260512_consultant_conversion_limits_phase_5a7_5a8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employees",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("employee_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("alternate_mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("date_of_joining", sa.Date(), nullable=True),
|
||||||
|
sa.Column("date_of_leaving", sa.Date(), nullable=True),
|
||||||
|
sa.Column("employment_type", sa.String(length=50), nullable=False, server_default="full_time"),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("department", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("designation", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("reporting_manager_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("pan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("uan", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("esi_no", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("pf_no", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("aadhaar_last4", sa.String(length=4), nullable=True),
|
||||||
|
sa.Column("bank_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("bank_account_no", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("bank_ifsc", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("address", sa.Text(), nullable=True),
|
||||||
|
sa.Column("emergency_contact_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("emergency_contact_mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["reporting_manager_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "employee_code", name="uq_employees_tenant_code"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "user_id", name="uq_employees_tenant_user"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "user_id", "employee_code", "full_name", "email", "status", "is_active", "department", "designation", "reporting_manager_user_id", "pan"]:
|
||||||
|
op.create_index(f"ix_employees_{col}", "employees", [col], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("employees")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Phase 6B employee ESS and registration requests
|
||||||
|
|
||||||
|
Revision ID: 20260514_phase_6b_employee_ess_registration
|
||||||
|
Revises: 20260513_phase_6a_employees_core
|
||||||
|
Create Date: 2026-05-10
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260514_phase_6b_employee_ess_registration"
|
||||||
|
down_revision = "20260513_phase_6a_employees_core"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employee_registration_requests",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("requested_employee_code", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("department", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("designation", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("date_of_joining", sa.Date(), nullable=True),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_employee_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_employee_id"], ["employees.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for col in [
|
||||||
|
"tenant_id",
|
||||||
|
"branch_id",
|
||||||
|
"user_id",
|
||||||
|
"requested_employee_code",
|
||||||
|
"full_name",
|
||||||
|
"email",
|
||||||
|
"department",
|
||||||
|
"designation",
|
||||||
|
"status",
|
||||||
|
"created_employee_id",
|
||||||
|
]:
|
||||||
|
op.create_index(f"ix_employee_registration_requests_{col}", "employee_registration_requests", [col], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("employee_registration_requests")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Phase 6C employee attendance foundation
|
||||||
|
|
||||||
|
Revision ID: 20260515_phase_6c_employee_attendance
|
||||||
|
Revises: 20260514_phase_6b_employee_ess_registration
|
||||||
|
Create Date: 2026-05-10
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260515_phase_6c_employee_attendance"
|
||||||
|
down_revision = "20260514_phase_6b_employee_ess_registration"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employee_attendance",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("attendance_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("punch_in_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("punch_out_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("work_duration_minutes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="present"),
|
||||||
|
sa.Column("approval_status", sa.String(length=30), nullable=False, server_default="approved"),
|
||||||
|
sa.Column("source", sa.String(length=30), nullable=False, server_default="self_punch"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "employee_id", "attendance_date", name="uq_employee_attendance_employee_date"),
|
||||||
|
)
|
||||||
|
for col in [
|
||||||
|
"tenant_id",
|
||||||
|
"branch_id",
|
||||||
|
"employee_id",
|
||||||
|
"user_id",
|
||||||
|
"attendance_date",
|
||||||
|
"status",
|
||||||
|
"approval_status",
|
||||||
|
"source",
|
||||||
|
]:
|
||||||
|
op.create_index(f"ix_employee_attendance_{col}", "employee_attendance", [col], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("employee_attendance")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Phase 6D employee leave management
|
||||||
|
|
||||||
|
Revision ID: 20260516_phase_6d_employee_leave
|
||||||
|
Revises: 20260515_phase_6c_employee_attendance
|
||||||
|
Create Date: 2026-05-10
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260516_phase_6d_employee_leave"
|
||||||
|
down_revision = "20260515_phase_6c_employee_attendance"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employee_leave_types",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("annual_quota_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("carry_forward_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("allow_negative_balance", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("requires_approval", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_paid", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_leave_types_tenant_branch_code"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "code", "is_active"]:
|
||||||
|
op.create_index(f"ix_employee_leave_types_{col}", "employee_leave_types", [col], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_leave_balances",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("leave_type_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("opening_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("credited_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("availed_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("adjusted_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("balance_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["leave_type_id"], ["employee_leave_types.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "employee_id", "leave_type_id", name="uq_employee_leave_balances_employee_type"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "employee_id", "leave_type_id"]:
|
||||||
|
op.create_index(f"ix_employee_leave_balances_{col}", "employee_leave_balances", [col], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_leave_requests",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("leave_type_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("from_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("to_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("days", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("request_source", sa.String(length=30), nullable=False, server_default="employee_portal"),
|
||||||
|
sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["leave_type_id"], ["employee_leave_types.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "employee_id", "user_id", "leave_type_id", "from_date", "to_date", "status", "request_source"]:
|
||||||
|
op.create_index(f"ix_employee_leave_requests_{col}", "employee_leave_requests", [col], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("employee_leave_requests")
|
||||||
|
op.drop_table("employee_leave_balances")
|
||||||
|
op.drop_table("employee_leave_types")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Phase 6E employee document management
|
||||||
|
|
||||||
|
Revision ID: 20260517_phase_6e_employee_documents
|
||||||
|
Revises: 20260516_phase_6d_employee_leave
|
||||||
|
Create Date: 2026-05-10
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260517_phase_6e_employee_documents"
|
||||||
|
down_revision = "20260516_phase_6d_employee_leave"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employee_document_types",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=150), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("allow_employee_upload", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("requires_verification", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_document_types_tenant_branch_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_employee_document_types_tenant_id", "employee_document_types", ["tenant_id"])
|
||||||
|
op.create_index("ix_employee_document_types_branch_id", "employee_document_types", ["branch_id"])
|
||||||
|
op.create_index("ix_employee_document_types_code", "employee_document_types", ["code"])
|
||||||
|
op.create_index("ix_employee_document_types_is_active", "employee_document_types", ["is_active"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_documents",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("document_type_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("title", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("document_no", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("issue_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("expiry_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("original_filename", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("stored_filename", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("storage_path", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(length=150), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="uploaded"),
|
||||||
|
sa.Column("visibility", sa.String(length=30), nullable=False, server_default="employee_and_hr"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("verified_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("verified_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("verification_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["document_type_id"], ["employee_document_types.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["verified_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_employee_documents_tenant_id", "employee_documents", ["tenant_id"])
|
||||||
|
op.create_index("ix_employee_documents_branch_id", "employee_documents", ["branch_id"])
|
||||||
|
op.create_index("ix_employee_documents_employee_id", "employee_documents", ["employee_id"])
|
||||||
|
op.create_index("ix_employee_documents_document_type_id", "employee_documents", ["document_type_id"])
|
||||||
|
op.create_index("ix_employee_documents_title", "employee_documents", ["title"])
|
||||||
|
op.create_index("ix_employee_documents_document_no", "employee_documents", ["document_no"])
|
||||||
|
op.create_index("ix_employee_documents_expiry_date", "employee_documents", ["expiry_date"])
|
||||||
|
op.create_index("ix_employee_documents_status", "employee_documents", ["status"])
|
||||||
|
op.create_index("ix_employee_documents_visibility", "employee_documents", ["visibility"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_employee_documents_visibility", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_status", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_expiry_date", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_document_no", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_title", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_document_type_id", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_employee_id", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_branch_id", table_name="employee_documents")
|
||||||
|
op.drop_index("ix_employee_documents_tenant_id", table_name="employee_documents")
|
||||||
|
op.drop_table("employee_documents")
|
||||||
|
op.drop_index("ix_employee_document_types_is_active", table_name="employee_document_types")
|
||||||
|
op.drop_index("ix_employee_document_types_code", table_name="employee_document_types")
|
||||||
|
op.drop_index("ix_employee_document_types_branch_id", table_name="employee_document_types")
|
||||||
|
op.drop_index("ix_employee_document_types_tenant_id", table_name="employee_document_types")
|
||||||
|
op.drop_table("employee_document_types")
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""DS6 permanent client document vault
|
||||||
|
|
||||||
|
Revision ID: 20260518_ds6_permanent_client_document_vault
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-18
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260518_ds6_permanent_client_document_vault"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"permanent_client_documents",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("document_code", sa.String(80), nullable=False),
|
||||||
|
sa.Column("category", sa.String(120), nullable=False, server_default="Other Permanent Documents"),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("current_version_no", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("deleted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "client_id", "document_code", name="uq_permanent_client_documents_code"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "client_id", "document_code", "category", "status", "is_deleted"]:
|
||||||
|
op.create_index(f"ix_permanent_client_documents_{col}", "permanent_client_documents", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"permanent_client_document_versions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("stored_filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(150), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("file_hash_sha256", sa.String(64), nullable=False),
|
||||||
|
sa.Column("storage_backend", sa.String(40), nullable=False, server_default="LOCAL_PERMANENT"),
|
||||||
|
sa.Column("local_relative_path", sa.String(1000), nullable=False),
|
||||||
|
sa.Column("storage_status", sa.String(30), nullable=False, server_default="stored"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("uploaded_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("document_id", "version_no", name="uq_permanent_client_document_versions_no"),
|
||||||
|
)
|
||||||
|
for col in ["document_id", "tenant_id", "branch_id", "client_id", "file_hash_sha256", "storage_backend", "storage_status"]:
|
||||||
|
op.create_index(f"ix_permanent_client_document_versions_{col}", "permanent_client_document_versions", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"permanent_document_storage_jobs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("version_id", sa.Integer(), sa.ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("job_type", sa.String(40), nullable=False, server_default="store_permanent_version"),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False, server_default="5"),
|
||||||
|
sa.Column("staging_relative_path", sa.String(1000), nullable=False),
|
||||||
|
sa.Column("target_relative_path", sa.String(1000), nullable=False),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("expected_hash_sha256", sa.String(64), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("picked_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("acknowledged_hash_sha256", sa.String(64), nullable=True),
|
||||||
|
sa.Column("local_final_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "job_type", "status", "priority", "expected_hash_sha256"]:
|
||||||
|
op.create_index(f"ix_permanent_document_storage_jobs_{col}", "permanent_document_storage_jobs", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"permanent_document_download_requests",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("version_id", sa.Integer(), sa.ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("request_status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("local_relative_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("expected_hash_sha256", sa.String(64), nullable=False),
|
||||||
|
sa.Column("cached_relative_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("cached_hash_sha256", sa.String(64), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("requested_ip", sa.String(80), nullable=True),
|
||||||
|
sa.Column("requested_user_agent", sa.String(500), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("fulfilled_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("failed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "request_status", "expected_hash_sha256", "requested_by_user_id"]:
|
||||||
|
op.create_index(f"ix_permanent_document_download_requests_{col}", "permanent_document_download_requests", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("permanent_document_download_requests")
|
||||||
|
op.drop_table("permanent_document_storage_jobs")
|
||||||
|
op.drop_table("permanent_client_document_versions")
|
||||||
|
op.drop_table("permanent_client_documents")
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""phase 6f employee onboarding and offboarding
|
||||||
|
|
||||||
|
Revision ID: 20260518_phase_6f_employee_onboarding_offboarding
|
||||||
|
Revises: 20260517_phase_6e_employee_documents
|
||||||
|
Create Date: 2026-05-18
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260518_phase_6f_employee_onboarding_offboarding"
|
||||||
|
down_revision = "20260517_phase_6e_employee_documents"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employee_onboarding_checklist_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("stage", sa.String(length=50), nullable=False, server_default="joining"),
|
||||||
|
sa.Column("default_due_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_onboarding_items_tenant_branch_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_employee_onboarding_checklist_items_tenant_id", "employee_onboarding_checklist_items", ["tenant_id"])
|
||||||
|
op.create_index("ix_employee_onboarding_checklist_items_branch_id", "employee_onboarding_checklist_items", ["branch_id"])
|
||||||
|
op.create_index("ix_employee_onboarding_checklist_items_code", "employee_onboarding_checklist_items", ["code"])
|
||||||
|
op.create_index("ix_employee_onboarding_checklist_items_is_active", "employee_onboarding_checklist_items", ["is_active"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_onboarding_tasks",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("checklist_item_id", sa.Integer(), sa.ForeignKey("employee_onboarding_checklist_items.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("title", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("stage", sa.String(length=50), nullable=False, server_default="joining"),
|
||||||
|
sa.Column("due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("assigned_to_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "employee_id", "checklist_item_id", name="uq_employee_onboarding_task_employee_item"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "employee_id", "checklist_item_id", "due_date", "status", "assigned_to_user_id"]:
|
||||||
|
op.create_index(f"ix_employee_onboarding_tasks_{col}", "employee_onboarding_tasks", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_offboarding_requests",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("request_type", sa.String(length=50), nullable=False, server_default="resignation"),
|
||||||
|
sa.Column("requested_relieving_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("approved_relieving_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("handover_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "employee_id", "user_id", "request_type", "requested_relieving_date", "approved_relieving_date", "status"]:
|
||||||
|
op.create_index(f"ix_employee_offboarding_requests_{col}", "employee_offboarding_requests", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_offboarding_tasks",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("request_id", sa.Integer(), sa.ForeignKey("employee_offboarding_requests.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("assigned_to_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "request_id", "employee_id", "due_date", "status", "assigned_to_user_id"]:
|
||||||
|
op.create_index(f"ix_employee_offboarding_tasks_{col}", "employee_offboarding_tasks", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("employee_offboarding_tasks")
|
||||||
|
op.drop_table("employee_offboarding_requests")
|
||||||
|
op.drop_table("employee_onboarding_tasks")
|
||||||
|
op.drop_table("employee_onboarding_checklist_items")
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Phase 6G employee payroll foundation
|
||||||
|
|
||||||
|
Revision ID: 20260519_phase_6g_employee_payroll
|
||||||
|
Revises: 20260518_phase_6f_employee_onboarding_offboarding
|
||||||
|
Create Date: 2026-05-19
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260519_phase_6g_employee_payroll"
|
||||||
|
down_revision = "20260518_phase_6f_employee_onboarding_offboarding"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"employee_salary_structures",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("effective_from", sa.Date(), nullable=False),
|
||||||
|
sa.Column("effective_to", sa.Date(), nullable=True),
|
||||||
|
sa.Column("pay_cycle", sa.String(length=30), nullable=False, server_default="monthly"),
|
||||||
|
sa.Column("monthly_ctc_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("basic_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("hra_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("allowance_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("employee_pf_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("employee_esi_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("professional_tax_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("tds_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("other_deduction_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "employee_id", "effective_from", name="uq_employee_salary_structure_effective"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_employee_salary_structures_tenant_id", "employee_salary_structures", ["tenant_id"])
|
||||||
|
op.create_index("ix_employee_salary_structures_branch_id", "employee_salary_structures", ["branch_id"])
|
||||||
|
op.create_index("ix_employee_salary_structures_employee_id", "employee_salary_structures", ["employee_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_payroll_runs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("pay_year", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("pay_month", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("run_name", sa.String(length=150), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="draft"),
|
||||||
|
sa.Column("total_employees", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("gross_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("deduction_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("net_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("processed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("approved_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("paid_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("paid_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "pay_year", "pay_month", name="uq_employee_payroll_run_period"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_employee_payroll_runs_tenant_id", "employee_payroll_runs", ["tenant_id"])
|
||||||
|
op.create_index("ix_employee_payroll_runs_branch_id", "employee_payroll_runs", ["branch_id"])
|
||||||
|
op.create_index("ix_employee_payroll_runs_period", "employee_payroll_runs", ["pay_year", "pay_month"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_payslips",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False),
|
||||||
|
sa.Column("payroll_run_id", sa.Integer(), sa.ForeignKey("employee_payroll_runs.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("salary_structure_id", sa.Integer(), sa.ForeignKey("employee_salary_structures.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("pay_year", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("pay_month", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("basic_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("hra_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("allowance_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("gross_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("employee_pf_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("employee_esi_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("professional_tax_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("tds_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("other_deduction_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("deduction_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("net_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="generated"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("generated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "payroll_run_id", "employee_id", name="uq_employee_payslip_run_employee"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_employee_payslips_tenant_id", "employee_payslips", ["tenant_id"])
|
||||||
|
op.create_index("ix_employee_payslips_branch_id", "employee_payslips", ["branch_id"])
|
||||||
|
op.create_index("ix_employee_payslips_payroll_run_id", "employee_payslips", ["payroll_run_id"])
|
||||||
|
op.create_index("ix_employee_payslips_employee_id", "employee_payslips", ["employee_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("employee_payslips")
|
||||||
|
op.drop_table("employee_payroll_runs")
|
||||||
|
op.drop_table("employee_salary_structures")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Phase 6K geo-fenced attendance and OD approval
|
||||||
|
|
||||||
|
Revision ID: 20260520_phase_6k_geo_attendance
|
||||||
|
Revises: 20260519_phase_6g_employee_payroll
|
||||||
|
Create Date: 2026-05-20
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260520_phase_6k_geo_attendance"
|
||||||
|
down_revision = "20260519_phase_6g_employee_payroll"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_geo_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_geo_radius_meters", sa.Integer(), nullable=False, server_default="100"))
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_ip_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_allowed_ip_csv", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_latitude", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_longitude", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_accuracy_meters", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_distance_meters", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_ip", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_geo_status", sa.String(length=40), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_ip_status", sa.String(length=40), nullable=True))
|
||||||
|
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_latitude", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_longitude", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_accuracy_meters", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_distance_meters", sa.Float(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_ip", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_geo_status", sa.String(length=40), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_ip_status", sa.String(length=40), nullable=True))
|
||||||
|
|
||||||
|
op.create_index("ix_employee_attendance_punch_in_geo_status", "employee_attendance", ["punch_in_geo_status"])
|
||||||
|
op.create_index("ix_employee_attendance_punch_in_ip_status", "employee_attendance", ["punch_in_ip_status"])
|
||||||
|
op.create_index("ix_employee_attendance_punch_out_geo_status", "employee_attendance", ["punch_out_geo_status"])
|
||||||
|
op.create_index("ix_employee_attendance_punch_out_ip_status", "employee_attendance", ["punch_out_ip_status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_employee_attendance_punch_out_ip_status", table_name="employee_attendance")
|
||||||
|
op.drop_index("ix_employee_attendance_punch_out_geo_status", table_name="employee_attendance")
|
||||||
|
op.drop_index("ix_employee_attendance_punch_in_ip_status", table_name="employee_attendance")
|
||||||
|
op.drop_index("ix_employee_attendance_punch_in_geo_status", table_name="employee_attendance")
|
||||||
|
|
||||||
|
for col in [
|
||||||
|
"punch_out_ip_status", "punch_out_geo_status", "punch_out_ip", "punch_out_distance_meters",
|
||||||
|
"punch_out_accuracy_meters", "punch_out_longitude", "punch_out_latitude",
|
||||||
|
"punch_in_ip_status", "punch_in_geo_status", "punch_in_ip", "punch_in_distance_meters",
|
||||||
|
"punch_in_accuracy_meters", "punch_in_longitude", "punch_in_latitude",
|
||||||
|
]:
|
||||||
|
op.drop_column("employee_attendance", col)
|
||||||
|
|
||||||
|
op.drop_column("branch_settings", "attendance_allowed_ip_csv")
|
||||||
|
op.drop_column("branch_settings", "attendance_ip_enabled")
|
||||||
|
op.drop_column("branch_settings", "attendance_geo_radius_meters")
|
||||||
|
op.drop_column("branch_settings", "attendance_geo_enabled")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Phase 6L branch timezone and attendance timing rules
|
||||||
|
|
||||||
|
Revision ID: 20260521_phase_6l_branch_timezone_attendance_rules
|
||||||
|
Revises: 20260520_phase_6k_geo_attendance
|
||||||
|
Create Date: 2026-05-21
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260521_phase_6l_branch_timezone_attendance_rules"
|
||||||
|
down_revision = "20260520_phase_6k_geo_attendance"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_grace_minutes", sa.Integer(), nullable=False, server_default="10"))
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_half_day_after_time", sa.Time(), nullable=True))
|
||||||
|
op.add_column("branch_settings", sa.Column("attendance_rule_enabled", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_in_local_at", sa.DateTime(timezone=False), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("punch_out_local_at", sa.DateTime(timezone=False), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("branch_timezone", sa.String(length=64), nullable=False, server_default="Asia/Kolkata"))
|
||||||
|
op.add_column("employee_attendance", sa.Column("scheduled_start_local", sa.Time(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("scheduled_end_local", sa.Time(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("late_by_minutes", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("attendance_rule_status", sa.String(length=40), nullable=True))
|
||||||
|
op.add_column("employee_attendance", sa.Column("is_weekly_off", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
op.create_index("ix_employee_attendance_attendance_rule_status", "employee_attendance", ["attendance_rule_status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_employee_attendance_attendance_rule_status", table_name="employee_attendance")
|
||||||
|
for col in [
|
||||||
|
"is_weekly_off",
|
||||||
|
"attendance_rule_status",
|
||||||
|
"late_by_minutes",
|
||||||
|
"scheduled_end_local",
|
||||||
|
"scheduled_start_local",
|
||||||
|
"branch_timezone",
|
||||||
|
"punch_out_local_at",
|
||||||
|
"punch_in_local_at",
|
||||||
|
]:
|
||||||
|
op.drop_column("employee_attendance", col)
|
||||||
|
|
||||||
|
op.drop_column("branch_settings", "attendance_rule_enabled")
|
||||||
|
op.drop_column("branch_settings", "attendance_half_day_after_time")
|
||||||
|
op.drop_column("branch_settings", "attendance_grace_minutes")
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Phase B1 B2 firm billing and fee structure import
|
||||||
|
|
||||||
|
Revision ID: 20260522_phase_b1_b2_billing
|
||||||
|
Revises: 20260521_phase_6l_branch_timezone_attendance_rules
|
||||||
|
Create Date: 2026-05-22
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260522_phase_b1_b2_billing"
|
||||||
|
down_revision = "20260521_phase_6l_branch_timezone_attendance_rules"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"billing_settings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("invoice_prefix", sa.String(length=40), nullable=False, server_default="INV"),
|
||||||
|
sa.Column("next_invoice_no", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("padding", sa.Integer(), nullable=False, server_default="4"),
|
||||||
|
sa.Column("default_gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
|
||||||
|
sa.Column("default_tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
|
||||||
|
sa.Column("legal_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("gstin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("billing_address", sa.Text(), nullable=True),
|
||||||
|
sa.Column("bank_details", sa.Text(), nullable=True),
|
||||||
|
sa.Column("terms", sa.Text(), nullable=True),
|
||||||
|
sa.Column("footer_note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_billing_settings_tenant_id", "billing_settings", ["tenant_id"])
|
||||||
|
op.create_index("ix_billing_settings_branch_id", "billing_settings", ["branch_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"billing_fee_groups",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("partner_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("group_code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("group_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("billing_mode", sa.String(length=20), nullable=False, server_default="PACKAGE"),
|
||||||
|
sa.Column("frequency", sa.String(length=20), nullable=False, server_default="Monthly"),
|
||||||
|
sa.Column("fee_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
|
||||||
|
sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
|
||||||
|
sa.Column("effective_from", sa.Date(), nullable=True),
|
||||||
|
sa.Column("effective_to", sa.Date(), nullable=True),
|
||||||
|
sa.Column("auto_generate", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["partner_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_billing_fee_groups_tenant_id", "billing_fee_groups", ["tenant_id"])
|
||||||
|
op.create_index("ix_billing_fee_groups_branch_id", "billing_fee_groups", ["branch_id"])
|
||||||
|
op.create_index("ix_billing_fee_groups_client_id", "billing_fee_groups", ["client_id"])
|
||||||
|
op.create_index("ix_billing_fee_groups_partner_id", "billing_fee_groups", ["partner_id"])
|
||||||
|
op.create_index("ix_billing_fee_groups_group_code", "billing_fee_groups", ["group_code"])
|
||||||
|
op.create_index("ix_billing_fee_groups_is_active", "billing_fee_groups", ["is_active"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"billing_invoices",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("invoice_no", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("invoice_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("billing_period_from", sa.Date(), nullable=True),
|
||||||
|
sa.Column("billing_period_to", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
|
||||||
|
sa.Column("subtotal", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("round_off", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("total_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("terms", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("approved_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["approved_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_billing_invoices_tenant_id", "billing_invoices", ["tenant_id"])
|
||||||
|
op.create_index("ix_billing_invoices_branch_id", "billing_invoices", ["branch_id"])
|
||||||
|
op.create_index("ix_billing_invoices_client_id", "billing_invoices", ["client_id"])
|
||||||
|
op.create_index("ix_billing_invoices_engagement_id", "billing_invoices", ["engagement_id"])
|
||||||
|
op.create_index("ix_billing_invoices_invoice_no", "billing_invoices", ["invoice_no"])
|
||||||
|
op.create_index("ix_billing_invoices_invoice_date", "billing_invoices", ["invoice_date"])
|
||||||
|
op.create_index("ix_billing_invoices_due_date", "billing_invoices", ["due_date"])
|
||||||
|
op.create_index("ix_billing_invoices_status", "billing_invoices", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"billing_fee_group_services",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("fee_group_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("line_description", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("allocation_type", sa.String(length=20), nullable=False, server_default="Included"),
|
||||||
|
sa.Column("line_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("percentage", sa.Numeric(5, 2), nullable=True),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.ForeignKeyConstraint(["fee_group_id"], ["billing_fee_groups.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_id"], ["service_catalogues.id"], ondelete="RESTRICT"),
|
||||||
|
sa.UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_billing_fee_group_services_fee_group_id", "billing_fee_group_services", ["fee_group_id"])
|
||||||
|
op.create_index("ix_billing_fee_group_services_service_id", "billing_fee_group_services", ["service_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"billing_invoice_lines",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("fee_group_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("description", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("billing_period_from", sa.Date(), nullable=True),
|
||||||
|
sa.Column("billing_period_to", sa.Date(), nullable=True),
|
||||||
|
sa.Column("quantity", sa.Numeric(12, 2), nullable=False, server_default="1.00"),
|
||||||
|
sa.Column("rate", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
|
||||||
|
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("line_total", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.ForeignKeyConstraint(["invoice_id"], ["billing_invoices.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_id"], ["service_catalogues.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["fee_group_id"], ["billing_fee_groups.id"], ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_billing_invoice_lines_invoice_id", "billing_invoice_lines", ["invoice_id"])
|
||||||
|
op.create_index("ix_billing_invoice_lines_service_id", "billing_invoice_lines", ["service_id"])
|
||||||
|
op.create_index("ix_billing_invoice_lines_engagement_id", "billing_invoice_lines", ["engagement_id"])
|
||||||
|
op.create_index("ix_billing_invoice_lines_fee_group_id", "billing_invoice_lines", ["fee_group_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("billing_invoice_lines")
|
||||||
|
op.drop_table("billing_fee_group_services")
|
||||||
|
op.drop_table("billing_invoices")
|
||||||
|
op.drop_table("billing_fee_groups")
|
||||||
|
op.drop_table("billing_settings")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Phase B3 generate draft invoices from fee structures
|
||||||
|
|
||||||
|
Revision ID: 20260523_phase_b3_billing_generation
|
||||||
|
Revises: 20260522_phase_b1_b2_billing
|
||||||
|
Create Date: 2026-05-23
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260523_phase_b3_billing_generation"
|
||||||
|
down_revision = "20260522_phase_b1_b2_billing"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"billing_invoice_generation_batches",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("billing_period_from", sa.Date(), nullable=False),
|
||||||
|
sa.Column("billing_period_to", sa.Date(), nullable=False),
|
||||||
|
sa.Column("frequency", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT_CREATED"),
|
||||||
|
sa.Column("selected_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_invoice_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("skipped_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("generated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("generated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["generated_by_user_id"], ["users.id"]),
|
||||||
|
)
|
||||||
|
op.create_index("ix_billing_invoice_generation_batches_tenant_id", "billing_invoice_generation_batches", ["tenant_id"])
|
||||||
|
op.create_index("ix_billing_invoice_generation_batches_branch_id", "billing_invoice_generation_batches", ["branch_id"])
|
||||||
|
op.create_index("ix_billing_invoice_generation_batches_billing_period_from", "billing_invoice_generation_batches", ["billing_period_from"])
|
||||||
|
op.create_index("ix_billing_invoice_generation_batches_billing_period_to", "billing_invoice_generation_batches", ["billing_period_to"])
|
||||||
|
op.create_index("ix_billing_invoice_generation_batches_frequency", "billing_invoice_generation_batches", ["frequency"])
|
||||||
|
op.create_index("ix_billing_invoice_generation_batches_status", "billing_invoice_generation_batches", ["status"])
|
||||||
|
|
||||||
|
with op.batch_alter_table("billing_invoices") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("generation_batch_id", sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
"fk_billing_invoices_generation_batch_id",
|
||||||
|
"billing_invoice_generation_batches",
|
||||||
|
["generation_batch_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
batch_op.create_index("ix_billing_invoices_generation_batch_id", ["generation_batch_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("billing_invoices") as batch_op:
|
||||||
|
batch_op.drop_index("ix_billing_invoices_generation_batch_id")
|
||||||
|
batch_op.drop_constraint("fk_billing_invoices_generation_batch_id", type_="foreignkey")
|
||||||
|
batch_op.drop_column("generation_batch_id")
|
||||||
|
|
||||||
|
op.drop_index("ix_billing_invoice_generation_batches_status", table_name="billing_invoice_generation_batches")
|
||||||
|
op.drop_index("ix_billing_invoice_generation_batches_frequency", table_name="billing_invoice_generation_batches")
|
||||||
|
op.drop_index("ix_billing_invoice_generation_batches_billing_period_to", table_name="billing_invoice_generation_batches")
|
||||||
|
op.drop_index("ix_billing_invoice_generation_batches_billing_period_from", table_name="billing_invoice_generation_batches")
|
||||||
|
op.drop_index("ix_billing_invoice_generation_batches_branch_id", table_name="billing_invoice_generation_batches")
|
||||||
|
op.drop_index("ix_billing_invoice_generation_batches_tenant_id", table_name="billing_invoice_generation_batches")
|
||||||
|
op.drop_table("billing_invoice_generation_batches")
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Phase PB1 platform SaaS billing foundation
|
||||||
|
|
||||||
|
Revision ID: 20260524_phase_pb1_platform_billing
|
||||||
|
Revises: 20260523_phase_b3_billing_generation
|
||||||
|
Create Date: 2026-05-24
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260524_phase_pb1_platform_billing"
|
||||||
|
down_revision = "20260523_phase_b3_billing_generation"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"platform_plans",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("target_account_type", sa.String(length=30), nullable=False, server_default="AUDIT_FIRM"),
|
||||||
|
sa.Column("billing_cycle", sa.String(length=20), nullable=False, server_default="Monthly"),
|
||||||
|
sa.Column("base_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("code", name="uq_platform_plans_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_plans_code", "platform_plans", ["code"])
|
||||||
|
op.create_index("ix_platform_plans_target_account_type", "platform_plans", ["target_account_type"])
|
||||||
|
op.create_index("ix_platform_plans_billing_cycle", "platform_plans", ["billing_cycle"])
|
||||||
|
op.create_index("ix_platform_plans_is_active", "platform_plans", ["is_active"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"platform_plan_features",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("plan_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("feature_code", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("feature_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("limit_value", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.ForeignKeyConstraint(["plan_id"], ["platform_plans.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_plan_features_plan_id", "platform_plan_features", ["plan_id"])
|
||||||
|
op.create_index("ix_platform_plan_features_feature_code", "platform_plan_features", ["feature_code"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"platform_billing_accounts",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("account_type", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("account_code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=220), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("gstin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("pan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("billing_address", sa.Text(), nullable=True),
|
||||||
|
sa.Column("state", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="ACTIVE"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["consultant_id"], ["consultant_profiles.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("account_type", "account_code", name="uq_platform_billing_accounts_type_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_billing_accounts_account_type", "platform_billing_accounts", ["account_type"])
|
||||||
|
op.create_index("ix_platform_billing_accounts_account_code", "platform_billing_accounts", ["account_code"])
|
||||||
|
op.create_index("ix_platform_billing_accounts_display_name", "platform_billing_accounts", ["display_name"])
|
||||||
|
op.create_index("ix_platform_billing_accounts_tenant_id", "platform_billing_accounts", ["tenant_id"])
|
||||||
|
op.create_index("ix_platform_billing_accounts_client_id", "platform_billing_accounts", ["client_id"])
|
||||||
|
op.create_index("ix_platform_billing_accounts_consultant_id", "platform_billing_accounts", ["consultant_id"])
|
||||||
|
op.create_index("ix_platform_billing_accounts_status", "platform_billing_accounts", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"platform_subscriptions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("plan_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("subscription_code", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("billing_cycle", sa.String(length=20), nullable=False, server_default="Monthly"),
|
||||||
|
sa.Column("amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="ACTIVE"),
|
||||||
|
sa.Column("auto_generate_invoice", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["plan_id"], ["platform_plans.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_subscriptions_account_id", "platform_subscriptions", ["account_id"])
|
||||||
|
op.create_index("ix_platform_subscriptions_plan_id", "platform_subscriptions", ["plan_id"])
|
||||||
|
op.create_index("ix_platform_subscriptions_subscription_code", "platform_subscriptions", ["subscription_code"])
|
||||||
|
op.create_index("ix_platform_subscriptions_billing_cycle", "platform_subscriptions", ["billing_cycle"])
|
||||||
|
op.create_index("ix_platform_subscriptions_status", "platform_subscriptions", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"platform_invoices",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("subscription_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("invoice_no", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("invoice_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("billing_period_from", sa.Date(), nullable=True),
|
||||||
|
sa.Column("billing_period_to", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"),
|
||||||
|
sa.Column("subtotal", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("total_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("posted_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["subscription_id"], ["platform_subscriptions.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["posted_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("invoice_no", name="uq_platform_invoices_invoice_no"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_invoices_account_id", "platform_invoices", ["account_id"])
|
||||||
|
op.create_index("ix_platform_invoices_subscription_id", "platform_invoices", ["subscription_id"])
|
||||||
|
op.create_index("ix_platform_invoices_invoice_no", "platform_invoices", ["invoice_no"])
|
||||||
|
op.create_index("ix_platform_invoices_invoice_date", "platform_invoices", ["invoice_date"])
|
||||||
|
op.create_index("ix_platform_invoices_due_date", "platform_invoices", ["due_date"])
|
||||||
|
op.create_index("ix_platform_invoices_status", "platform_invoices", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"platform_invoice_lines",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("charge_type", sa.String(length=40), nullable=False, server_default="SUBSCRIPTION"),
|
||||||
|
sa.Column("description", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("reference_type", sa.String(length=60), nullable=True),
|
||||||
|
sa.Column("reference_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("quantity", sa.Numeric(12, 2), nullable=False, server_default="1.00"),
|
||||||
|
sa.Column("rate", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"),
|
||||||
|
sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("line_total", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.ForeignKeyConstraint(["invoice_id"], ["platform_invoices.id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_invoice_lines_invoice_id", "platform_invoice_lines", ["invoice_id"])
|
||||||
|
op.create_index("ix_platform_invoice_lines_charge_type", "platform_invoice_lines", ["charge_type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"platform_payments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("payment_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("amount", sa.Numeric(14, 2), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=30), nullable=False, server_default="Bank"),
|
||||||
|
sa.Column("reference_no", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["invoice_id"], ["platform_invoices.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
)
|
||||||
|
op.create_index("ix_platform_payments_invoice_id", "platform_payments", ["invoice_id"])
|
||||||
|
op.create_index("ix_platform_payments_account_id", "platform_payments", ["account_id"])
|
||||||
|
op.create_index("ix_platform_payments_payment_date", "platform_payments", ["payment_date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_platform_payments_payment_date", table_name="platform_payments")
|
||||||
|
op.drop_index("ix_platform_payments_account_id", table_name="platform_payments")
|
||||||
|
op.drop_index("ix_platform_payments_invoice_id", table_name="platform_payments")
|
||||||
|
op.drop_table("platform_payments")
|
||||||
|
op.drop_index("ix_platform_invoice_lines_charge_type", table_name="platform_invoice_lines")
|
||||||
|
op.drop_index("ix_platform_invoice_lines_invoice_id", table_name="platform_invoice_lines")
|
||||||
|
op.drop_table("platform_invoice_lines")
|
||||||
|
op.drop_index("ix_platform_invoices_status", table_name="platform_invoices")
|
||||||
|
op.drop_index("ix_platform_invoices_due_date", table_name="platform_invoices")
|
||||||
|
op.drop_index("ix_platform_invoices_invoice_date", table_name="platform_invoices")
|
||||||
|
op.drop_index("ix_platform_invoices_invoice_no", table_name="platform_invoices")
|
||||||
|
op.drop_index("ix_platform_invoices_subscription_id", table_name="platform_invoices")
|
||||||
|
op.drop_index("ix_platform_invoices_account_id", table_name="platform_invoices")
|
||||||
|
op.drop_table("platform_invoices")
|
||||||
|
op.drop_index("ix_platform_subscriptions_status", table_name="platform_subscriptions")
|
||||||
|
op.drop_index("ix_platform_subscriptions_billing_cycle", table_name="platform_subscriptions")
|
||||||
|
op.drop_index("ix_platform_subscriptions_subscription_code", table_name="platform_subscriptions")
|
||||||
|
op.drop_index("ix_platform_subscriptions_plan_id", table_name="platform_subscriptions")
|
||||||
|
op.drop_index("ix_platform_subscriptions_account_id", table_name="platform_subscriptions")
|
||||||
|
op.drop_table("platform_subscriptions")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_status", table_name="platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_consultant_id", table_name="platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_client_id", table_name="platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_tenant_id", table_name="platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_display_name", table_name="platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_account_code", table_name="platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_billing_accounts_account_type", table_name="platform_billing_accounts")
|
||||||
|
op.drop_table("platform_billing_accounts")
|
||||||
|
op.drop_index("ix_platform_plan_features_feature_code", table_name="platform_plan_features")
|
||||||
|
op.drop_index("ix_platform_plan_features_plan_id", table_name="platform_plan_features")
|
||||||
|
op.drop_table("platform_plan_features")
|
||||||
|
op.drop_index("ix_platform_plans_is_active", table_name="platform_plans")
|
||||||
|
op.drop_index("ix_platform_plans_billing_cycle", table_name="platform_plans")
|
||||||
|
op.drop_index("ix_platform_plans_target_account_type", table_name="platform_plans")
|
||||||
|
op.drop_index("ix_platform_plans_code", table_name="platform_plans")
|
||||||
|
op.drop_table("platform_plans")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Phase PB5 marketplace lead foundation
|
||||||
|
|
||||||
|
Revision ID: 20260525_phase_pb5_marketplace_leads
|
||||||
|
Revises: 20260524_phase_pb1_platform_billing
|
||||||
|
Create Date: 2026-05-25
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260525_phase_pb5_marketplace_leads"
|
||||||
|
down_revision = "20260524_phase_pb1_platform_billing"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"marketplace_leads",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("lead_no", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("source", sa.String(length=50), nullable=False, server_default="manual"),
|
||||||
|
sa.Column("service_category", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("service_requested", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("lead_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("business_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("city", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("state", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"),
|
||||||
|
sa.Column("priority", sa.String(length=30), nullable=False, server_default="NORMAL"),
|
||||||
|
sa.Column("estimated_value", sa.Numeric(12, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("assigned_tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("assigned_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("converted_client_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("converted_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("converted_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_tenant_id"], ["tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["converted_client_id"], ["clients.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["converted_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("lead_no", name="uq_marketplace_leads_lead_no"),
|
||||||
|
)
|
||||||
|
for col in ["lead_no", "source", "service_category", "service_requested", "lead_name", "business_name", "email", "mobile", "status", "priority", "assigned_tenant_id", "assigned_branch_id", "assigned_partner_user_id", "converted_client_id"]:
|
||||||
|
op.create_index(f"ix_marketplace_leads_{col}", "marketplace_leads", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"marketplace_lead_assignments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("lead_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="ASSIGNED"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("assigned_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["lead_id"], ["marketplace_leads.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["partner_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"]),
|
||||||
|
)
|
||||||
|
for col in ["lead_id", "tenant_id", "branch_id", "partner_user_id", "status"]:
|
||||||
|
op.create_index(f"ix_marketplace_lead_assignments_{col}", "marketplace_lead_assignments", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("marketplace_lead_assignments")
|
||||||
|
op.drop_table("marketplace_leads")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""phase ds1 ds2 engagement documents
|
||||||
|
|
||||||
|
Revision ID: 20260526_phase_ds1_ds2_documents
|
||||||
|
Revises: 20260525_phase_pb5_marketplace_leads
|
||||||
|
Create Date: 2026-05-14
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260526_phase_ds1_ds2_documents"
|
||||||
|
down_revision = "20260525_phase_pb5_marketplace_leads"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"engagement_documents",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("financial_year", sa.String(length=9), nullable=False),
|
||||||
|
sa.Column("assessment_year", sa.String(length=9), nullable=True),
|
||||||
|
sa.Column("document_code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("document_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("current_version_no", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("deleted_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["deleted_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "engagement_id", "document_code", name="uq_engagement_documents_code"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "client_id", "engagement_id", "financial_year", "assessment_year", "document_code", "document_type", "status", "is_deleted"]:
|
||||||
|
op.create_index(f"ix_engagement_documents_{col}", "engagement_documents", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"engagement_document_versions",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("document_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("original_filename", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("stored_filename", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(length=150), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("file_hash_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("storage_backend", sa.String(length=40), nullable=False, server_default="LOCAL_YEAR_WISE"),
|
||||||
|
sa.Column("local_relative_path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("storage_status", sa.String(length=30), nullable=False, server_default="stored"),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("document_id", "version_no", name="uq_engagement_document_versions_no"),
|
||||||
|
)
|
||||||
|
for col in ["document_id", "tenant_id", "branch_id", "client_id", "engagement_id", "file_hash_sha256", "storage_backend", "storage_status"]:
|
||||||
|
op.create_index(f"ix_engagement_document_versions_{col}", "engagement_document_versions", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"document_access_logs",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("document_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("version_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("result", sa.String(length=40), nullable=False, server_default="success"),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("ip_address", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "client_id", "engagement_id", "document_id", "version_id", "action", "result", "user_id"]:
|
||||||
|
op.create_index(f"ix_document_access_logs_{col}", "document_access_logs", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("document_access_logs")
|
||||||
|
op.drop_table("engagement_document_versions")
|
||||||
|
op.drop_table("engagement_documents")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""phase ds3 ds4 branch storage nodes and document jobs
|
||||||
|
|
||||||
|
Revision ID: 20260527_phase_ds3_ds4_storage_jobs
|
||||||
|
Revises: 20260526_phase_ds1_ds2_documents
|
||||||
|
Create Date: 2026-05-14
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260527_phase_ds3_ds4_storage_jobs"
|
||||||
|
down_revision = "20260526_phase_ds1_ds2_documents"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"branch_storage_nodes",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("node_code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("node_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("connector_url", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("secret_key_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("storage_root_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("storage_mode", sa.String(length=40), nullable=False, server_default="pull_jobs"),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("last_seen_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_seen_ip", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("quota_limit_bytes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("used_storage_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("subscription_required", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "node_code", name="uq_branch_storage_node_code"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "node_code", "storage_mode", "status", "is_active"]:
|
||||||
|
op.create_index(f"ix_branch_storage_nodes_{col}", "branch_storage_nodes", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"document_storage_jobs",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("storage_node_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("document_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("job_type", sa.String(length=40), nullable=False, server_default="store_version"),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False, server_default="5"),
|
||||||
|
sa.Column("staging_relative_path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("target_relative_path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("expected_hash_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("picked_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("acknowledged_hash_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("local_final_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["storage_node_id"], ["branch_storage_nodes.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "job_type", "status", "priority", "expected_hash_sha256"]:
|
||||||
|
op.create_index(f"ix_document_storage_jobs_{col}", "document_storage_jobs", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("document_storage_jobs")
|
||||||
|
op.drop_table("branch_storage_nodes")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""phase ds5 secure local download streaming requests
|
||||||
|
|
||||||
|
Revision ID: 20260528_phase_ds5_secure_download_streaming
|
||||||
|
Revises: 20260527_phase_ds3_ds4_storage_jobs
|
||||||
|
Create Date: 2026-05-14
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260528_phase_ds5_secure_download_streaming"
|
||||||
|
down_revision = "20260527_phase_ds3_ds4_storage_jobs"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"document_download_requests",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("storage_node_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("document_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("request_status", sa.String(length=30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("local_relative_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("expected_hash_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("cached_relative_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("cached_hash_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("requested_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("requested_ip", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("requested_user_agent", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("fulfilled_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("failed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["storage_node_id"], ["branch_storage_nodes.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for col in [
|
||||||
|
"tenant_id",
|
||||||
|
"branch_id",
|
||||||
|
"storage_node_id",
|
||||||
|
"document_id",
|
||||||
|
"version_id",
|
||||||
|
"request_status",
|
||||||
|
"expected_hash_sha256",
|
||||||
|
"requested_by_user_id",
|
||||||
|
]:
|
||||||
|
op.create_index(f"ix_document_download_requests_{col}", "document_download_requests", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("document_download_requests")
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Phase 7H common alerts foundation
|
||||||
|
|
||||||
|
Revision ID: 20260529_phase_7h_common_alerts
|
||||||
|
Revises: af48d99d7321
|
||||||
|
Create Date: 2026-05-20
|
||||||
|
|
||||||
|
This migration is intentionally idempotent for SQLite/dev environments.
|
||||||
|
If a previous failed Alembic run already created user_alerts before the
|
||||||
|
revision was recorded, re-running `alembic upgrade head` will safely continue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260529_phase_7h_common_alerts"
|
||||||
|
down_revision = "af48d99d7321"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLE_NAME = "user_alerts"
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_columns(inspector: sa.Inspector, table_name: str) -> set[str]:
|
||||||
|
if not _table_exists(inspector, table_name):
|
||||||
|
return set()
|
||||||
|
return {col["name"] for col in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_indexes(inspector: sa.Inspector, table_name: str) -> set[str]:
|
||||||
|
if not _table_exists(inspector, table_name):
|
||||||
|
return set()
|
||||||
|
return {idx["name"] for idx in inspector.get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(inspector: sa.Inspector, column_name: str, column: sa.Column) -> None:
|
||||||
|
existing = _existing_columns(inspector, TABLE_NAME)
|
||||||
|
if column_name not in existing:
|
||||||
|
op.add_column(TABLE_NAME, column)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(inspector: sa.Inspector, index_name: str, columns: list[str]) -> None:
|
||||||
|
existing = _existing_indexes(inspector, TABLE_NAME)
|
||||||
|
if index_name not in existing:
|
||||||
|
op.create_index(index_name, TABLE_NAME, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if not _table_exists(inspector, TABLE_NAME):
|
||||||
|
op.create_table(
|
||||||
|
TABLE_NAME,
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("role_context", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("alert_type", sa.String(length=80), nullable=False, server_default="general"),
|
||||||
|
sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("title", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("target_url", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("read_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
else:
|
||||||
|
# Safety path for a failed/partial migration run. SQLite cannot add
|
||||||
|
# foreign-key constraints after table creation, but it can add any
|
||||||
|
# missing columns so the model and UI continue to work.
|
||||||
|
_add_column_if_missing(inspector, "tenant_id", sa.Column("tenant_id", sa.Integer(), nullable=True))
|
||||||
|
_add_column_if_missing(inspector, "branch_id", sa.Column("branch_id", sa.Integer(), nullable=True))
|
||||||
|
_add_column_if_missing(inspector, "user_id", sa.Column("user_id", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
_add_column_if_missing(inspector, "role_context", sa.Column("role_context", sa.String(length=50), nullable=True))
|
||||||
|
_add_column_if_missing(inspector, "alert_type", sa.Column("alert_type", sa.String(length=80), nullable=False, server_default="general"))
|
||||||
|
_add_column_if_missing(inspector, "priority", sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"))
|
||||||
|
_add_column_if_missing(inspector, "title", sa.Column("title", sa.String(length=255), nullable=False, server_default="Alert"))
|
||||||
|
_add_column_if_missing(inspector, "message", sa.Column("message", sa.Text(), nullable=True))
|
||||||
|
_add_column_if_missing(inspector, "target_url", sa.Column("target_url", sa.String(length=500), nullable=True))
|
||||||
|
_add_column_if_missing(inspector, "is_read", sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
_add_column_if_missing(inspector, "read_at_utc", sa.Column("read_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
_add_column_if_missing(inspector, "created_at_utc", sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
|
||||||
|
_add_column_if_missing(inspector, "created_by_user_id", sa.Column("created_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_user_id", ["user_id"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_role_context", ["role_context"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_alert_type", ["alert_type"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_priority", ["priority"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_is_read", ["is_read"])
|
||||||
|
_create_index_if_missing(inspector, "ix_user_alerts_created_at_utc", ["created_at_utc"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not _table_exists(inspector, TABLE_NAME):
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_indexes = _existing_indexes(inspector, TABLE_NAME)
|
||||||
|
for index_name in [
|
||||||
|
"ix_user_alerts_created_at_utc",
|
||||||
|
"ix_user_alerts_is_read",
|
||||||
|
"ix_user_alerts_priority",
|
||||||
|
"ix_user_alerts_alert_type",
|
||||||
|
"ix_user_alerts_role_context",
|
||||||
|
"ix_user_alerts_user_id",
|
||||||
|
"ix_user_alerts_branch_id",
|
||||||
|
"ix_user_alerts_tenant_id",
|
||||||
|
]:
|
||||||
|
if index_name in existing_indexes:
|
||||||
|
op.drop_index(index_name, table_name=TABLE_NAME)
|
||||||
|
|
||||||
|
op.drop_table(TABLE_NAME)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Phase 7Q.2 firm branding settings
|
||||||
|
|
||||||
|
Revision ID: 20260530_phase_7q2_firm_branding
|
||||||
|
Revises: 20260529_phase_7h_common_alerts
|
||||||
|
Create Date: 2026-05-21
|
||||||
|
|
||||||
|
Adds tenant-level branding fields and branch-level invoice/payment presentation fields.
|
||||||
|
The migration is idempotent for SQLite/dev environments.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260530_phase_7q2_firm_branding"
|
||||||
|
down_revision = "20260529_phase_7h_common_alerts"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_if_missing(table: str, column: sa.Column) -> None:
|
||||||
|
if column.name not in _columns(table):
|
||||||
|
op.add_column(table, column)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_if_missing("tenants", sa.Column("display_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("logo_path", sa.String(length=500), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("favicon_path", sa.String(length=500), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("accent_color", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("website_url", sa.String(length=255), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("contact_email", sa.String(length=255), nullable=True))
|
||||||
|
_add_if_missing("tenants", sa.Column("contact_mobile", sa.String(length=50), nullable=True))
|
||||||
|
|
||||||
|
_add_if_missing("branch_settings", sa.Column("invoice_footer_text", sa.Text(), nullable=True))
|
||||||
|
_add_if_missing("branch_settings", sa.Column("bank_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("branch_settings", sa.Column("bank_account_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("branch_settings", sa.Column("bank_account_number", sa.String(length=50), nullable=True))
|
||||||
|
_add_if_missing("branch_settings", sa.Column("bank_ifsc", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("branch_settings", sa.Column("upi_id", sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# SQLite cannot reliably drop columns on older setups without table rebuild.
|
||||||
|
# Keep downgrade no-op to avoid damaging live tenant/branch settings.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Phase 7Q.3 user profile photo and qualification fields
|
||||||
|
|
||||||
|
Revision ID: 20260531_phase_7q3_user_profile
|
||||||
|
Revises: 20260530_phase_7q2_firm_branding
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260531_phase_7q3_user_profile"
|
||||||
|
down_revision = "20260530_phase_7q2_firm_branding"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return {col["name"] for col in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
existing = _columns("users")
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
if "profile_photo_path" not in existing:
|
||||||
|
batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=500), nullable=True))
|
||||||
|
if "qualification" not in existing:
|
||||||
|
batch_op.add_column(sa.Column("qualification", sa.String(length=200), nullable=True))
|
||||||
|
if "designation" not in existing:
|
||||||
|
batch_op.add_column(sa.Column("designation", sa.String(length=200), nullable=True))
|
||||||
|
if "mobile" not in existing:
|
||||||
|
batch_op.add_column(sa.Column("mobile", sa.String(length=30), nullable=True))
|
||||||
|
if "bio" not in existing:
|
||||||
|
batch_op.add_column(sa.Column("bio", sa.Text(), nullable=True))
|
||||||
|
if "signature_image_path" not in existing:
|
||||||
|
batch_op.add_column(sa.Column("signature_image_path", sa.String(length=500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
existing = _columns("users")
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
for column_name in ["signature_image_path", "bio", "mobile", "designation", "qualification", "profile_photo_path"]:
|
||||||
|
if column_name in existing:
|
||||||
|
batch_op.drop_column(column_name)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Phase 7R.1 firm billing settings
|
||||||
|
|
||||||
|
Revision ID: 20260601_phase_7r1_billing_settings
|
||||||
|
Revises: 20260531_phase_7q3_user_profile
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Adds professional billing settings fields used by GST invoice formatting,
|
||||||
|
payment tracking and future online payment phases. Idempotent for SQLite dev use.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260601_phase_7r1_billing_settings"
|
||||||
|
down_revision = "20260531_phase_7q3_user_profile"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_if_missing(table: str, column: sa.Column) -> None:
|
||||||
|
if column.name not in _columns(table):
|
||||||
|
op.add_column(table, column)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_if_missing("billing_settings", sa.Column("pan", sa.String(length=10), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("state_code", sa.String(length=2), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("contact_email", sa.String(length=255), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("contact_mobile", sa.String(length=50), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("website_url", sa.String(length=255), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("invoice_title", sa.String(length=80), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("invoice_number_format", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("default_due_days", sa.Integer(), nullable=False, server_default="15"))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("default_sac_code", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("bank_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("bank_account_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("bank_account_number", sa.String(length=50), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("bank_ifsc", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("upi_id", sa.String(length=100), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("authorised_signatory_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("declaration", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# No-op downgrade for SQLite/dev safety. Dropping columns in SQLite can require
|
||||||
|
# table rebuild and may damage live billing configuration.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Phase 7R.2 GST invoice format
|
||||||
|
|
||||||
|
Revision ID: 20260602_phase_7r2_gst_invoice
|
||||||
|
Revises: 20260601_phase_7r1_billing_settings
|
||||||
|
Create Date: 2026-06-02
|
||||||
|
|
||||||
|
Adds GST invoice snapshot fields, place of supply, reverse charge,
|
||||||
|
SAC code per line and amount-in-words support. The migration is
|
||||||
|
idempotent for the existing SQLite development workflow.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260602_phase_7r2_gst_invoice"
|
||||||
|
down_revision = "20260601_phase_7r1_billing_settings"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_if_missing(table: str, column: sa.Column) -> None:
|
||||||
|
if column.name not in _columns(table):
|
||||||
|
op.add_column(table, column)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("invoice_title", sa.String(length=80), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("place_of_supply", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("reverse_charge", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_legal_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_trade_name", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_gstin", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_pan", sa.String(length=20), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_billing_address", sa.Text(), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_state", sa.String(length=100), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_state_code", sa.String(length=2), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_email", sa.String(length=255), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("client_mobile", sa.String(length=50), nullable=True))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("amount_in_words", sa.String(length=500), nullable=True))
|
||||||
|
_add_if_missing("billing_invoice_lines", sa.Column("sac_code", sa.String(length=20), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# No-op downgrade for SQLite/dev safety. Dropping columns in SQLite can
|
||||||
|
# require table rebuild and may damage existing billing records.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Phase 7R.3 payment tracking and receipts
|
||||||
|
|
||||||
|
Revision ID: 20260603_phase_7r3_payments
|
||||||
|
Revises: 20260602_phase_7r2_gst_invoice
|
||||||
|
Create Date: 2026-06-03
|
||||||
|
|
||||||
|
Adds invoice collection totals and a payment/receipt ledger. The migration
|
||||||
|
is intentionally idempotent for SQLite development databases used in this
|
||||||
|
project.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260603_phase_7r3_payments"
|
||||||
|
down_revision = "20260602_phase_7r2_gst_invoice"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _tables() -> set[str]:
|
||||||
|
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_if_missing(table: str, column: sa.Column) -> None:
|
||||||
|
if column.name not in _columns(table):
|
||||||
|
op.add_column(table, column)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
for table_name in inspector.get_table_names():
|
||||||
|
for idx in inspector.get_indexes(table_name):
|
||||||
|
if idx.get("name") == index_name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("amount_received", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("tds_deducted", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("bank_charges", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
|
||||||
|
_add_if_missing("billing_invoices", sa.Column("balance_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"))
|
||||||
|
|
||||||
|
if "billing_payments" not in _tables():
|
||||||
|
op.create_table(
|
||||||
|
"billing_payments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("invoice_id", sa.Integer(), sa.ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("receipt_no", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("receipt_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("payment_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("amount_received", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("tds_deducted", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("bank_charges", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("mode", sa.String(length=30), nullable=False, server_default="BANK"),
|
||||||
|
sa.Column("reference_no", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("payment_gateway", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("gateway_transaction_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="RECEIVED"),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"),
|
||||||
|
)
|
||||||
|
for name, cols in {
|
||||||
|
"ix_billing_payments_tenant_id": ["tenant_id"],
|
||||||
|
"ix_billing_payments_branch_id": ["branch_id"],
|
||||||
|
"ix_billing_payments_invoice_id": ["invoice_id"],
|
||||||
|
"ix_billing_payments_client_id": ["client_id"],
|
||||||
|
"ix_billing_payments_receipt_no": ["receipt_no"],
|
||||||
|
"ix_billing_payments_payment_date": ["payment_date"],
|
||||||
|
"ix_billing_payments_status": ["status"],
|
||||||
|
}.items():
|
||||||
|
if not _index_exists(name):
|
||||||
|
op.create_index(name, "billing_payments", cols)
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
if "billing_invoices" in _tables():
|
||||||
|
bind.execute(sa.text("UPDATE billing_invoices SET balance_amount = COALESCE(total_amount, 0) WHERE COALESCE(balance_amount, 0) = 0 AND COALESCE(amount_received, 0) = 0 AND COALESCE(tds_deducted, 0) = 0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# No-op downgrade for SQLite/dev safety.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Phase 7R.6 PayUMoney online payment gateway
|
||||||
|
|
||||||
|
Revision ID: 20260606_phase_7r6_payumoney
|
||||||
|
Revises: 20260603_phase_7r3_payments
|
||||||
|
Create Date: 2026-06-06
|
||||||
|
|
||||||
|
Adds PayUMoney settings and a small online payment transaction ledger.
|
||||||
|
The migration is intentionally defensive for SQLite development databases.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260606_phase_7r6_payumoney"
|
||||||
|
down_revision = "20260603_phase_7r3_payments"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _tables() -> set[str]:
|
||||||
|
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_if_missing(table: str, column: sa.Column) -> None:
|
||||||
|
if table in _tables() and column.name not in _columns(table):
|
||||||
|
op.add_column(table, column)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
for table_name in inspector.get_table_names():
|
||||||
|
for idx in inspector.get_indexes(table_name):
|
||||||
|
if idx.get("name") == index_name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(name: str, table: str, columns: list[str], unique: bool = False) -> None:
|
||||||
|
if table in _tables() and not _index_exists(name):
|
||||||
|
op.create_index(name, table, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_if_missing("billing_settings", sa.Column("payumoney_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("payumoney_mode", sa.String(length=20), nullable=False, server_default="TEST"))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("payumoney_merchant_key", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("payumoney_merchant_salt", sa.String(length=200), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("payumoney_merchant_id", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("payumoney_product_info", sa.String(length=200), nullable=True))
|
||||||
|
|
||||||
|
if "billing_online_payment_transactions" not in _tables():
|
||||||
|
op.create_table(
|
||||||
|
"billing_online_payment_transactions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=40), nullable=False, server_default="PAYUMONEY"),
|
||||||
|
sa.Column("mode", sa.String(length=20), nullable=False, server_default="TEST"),
|
||||||
|
sa.Column("txnid", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"),
|
||||||
|
sa.Column("productinfo", sa.String(length=250), nullable=True),
|
||||||
|
sa.Column("firstname", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("payu_payment_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("bank_ref_num", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("mihpayid", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="INITIATED"),
|
||||||
|
sa.Column("gateway_status", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("response_hash", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("raw_response", sa.Text(), nullable=True),
|
||||||
|
sa.Column("receipt_payment_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["invoice_id"], ["billing_invoices.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["receipt_payment_id"], ["billing_payments.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"),
|
||||||
|
)
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_tenant_id", "billing_online_payment_transactions", ["tenant_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_branch_id", "billing_online_payment_transactions", ["branch_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_invoice_id", "billing_online_payment_transactions", ["invoice_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_client_id", "billing_online_payment_transactions", ["client_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_txnid", "billing_online_payment_transactions", ["txnid"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_status", "billing_online_payment_transactions", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if "billing_online_payment_transactions" in _tables():
|
||||||
|
op.drop_table("billing_online_payment_transactions")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Phase 7R.6A Cashfree payment gateway integration
|
||||||
|
|
||||||
|
Revision ID: 20260607_phase_7r6a_cashfree
|
||||||
|
Revises: 20260606_phase_7r6_payumoney
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
Adds Cashfree gateway settings and generic Cashfree fields to the existing online
|
||||||
|
payment transaction ledger. Defensive for SQLite development databases.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260607_phase_7r6a_cashfree"
|
||||||
|
down_revision = "20260606_phase_7r6_payumoney"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _tables() -> set[str]:
|
||||||
|
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table_name: str) -> set[str]:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return set()
|
||||||
|
return {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_if_missing(table: str, column: sa.Column) -> None:
|
||||||
|
if table in _tables() and column.name not in _columns(table):
|
||||||
|
op.add_column(table, column)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
for table_name in inspector.get_table_names():
|
||||||
|
for idx in inspector.get_indexes(table_name):
|
||||||
|
if idx.get("name") == index_name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(name: str, table: str, columns: list[str], unique: bool = False) -> None:
|
||||||
|
if table in _tables() and not _index_exists(name):
|
||||||
|
op.create_index(name, table, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_if_missing("billing_settings", sa.Column("cashfree_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("cashfree_mode", sa.String(length=20), nullable=False, server_default="TEST"))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("cashfree_client_id", sa.String(length=180), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("cashfree_client_secret", sa.String(length=240), nullable=True))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("cashfree_api_version", sa.String(length=20), nullable=False, server_default="2023-08-01"))
|
||||||
|
_add_if_missing("billing_settings", sa.Column("cashfree_order_note", sa.String(length=250), nullable=True))
|
||||||
|
|
||||||
|
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_order_id", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_cf_order_id", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_payment_session_id", sa.String(length=500), nullable=True))
|
||||||
|
_add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_payment_id", sa.String(length=120), nullable=True))
|
||||||
|
_add_if_missing("billing_online_payment_transactions", sa.Column("webhook_event_id", sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_cashfree_order_id", "billing_online_payment_transactions", ["cashfree_order_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_cashfree_cf_order_id", "billing_online_payment_transactions", ["cashfree_cf_order_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_cashfree_payment_id", "billing_online_payment_transactions", ["cashfree_payment_id"])
|
||||||
|
_create_index_if_missing("ix_billing_online_payment_transactions_webhook_event_id", "billing_online_payment_transactions", ["webhook_event_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# SQLite cannot safely drop columns in older versions. Keep columns to avoid data loss.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260608_phase_7s1_email_notifications"
|
||||||
|
down_revision = "20260607_phase_7r6a_cashfree"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _has_table(bind, "email_settings"):
|
||||||
|
op.create_table(
|
||||||
|
"email_settings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("smtp_host", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("smtp_port", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("smtp_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("smtp_password", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("smtp_security", sa.String(length=20), nullable=False, server_default="SSL"),
|
||||||
|
sa.Column("smtp_timeout_seconds", sa.Integer(), nullable=False, server_default="20"),
|
||||||
|
sa.Column("from_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("from_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("reply_to_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("imap_host", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("imap_port", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("imap_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("imap_password", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("imap_security", sa.String(length=20), nullable=False, server_default="SSL"),
|
||||||
|
sa.Column("send_auth_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("send_alert_emails", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("send_billing_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("send_task_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", name="uq_email_settings_tenant_branch"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_email_settings_tenant_id", "email_settings", ["tenant_id"])
|
||||||
|
op.create_index("ix_email_settings_branch_id", "email_settings", ["branch_id"])
|
||||||
|
op.create_index("ix_email_settings_is_active", "email_settings", ["is_active"])
|
||||||
|
|
||||||
|
if not _has_table(bind, "email_templates"):
|
||||||
|
op.create_table(
|
||||||
|
"email_templates",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("template_code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("template_name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("subject_template", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("body_template", sa.Text(), nullable=False),
|
||||||
|
sa.Column("is_html", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "template_code", name="uq_email_templates_scope_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_email_templates_tenant_id", "email_templates", ["tenant_id"])
|
||||||
|
op.create_index("ix_email_templates_branch_id", "email_templates", ["branch_id"])
|
||||||
|
op.create_index("ix_email_templates_template_code", "email_templates", ["template_code"])
|
||||||
|
op.create_index("ix_email_templates_is_active", "email_templates", ["is_active"])
|
||||||
|
|
||||||
|
if not _has_table(bind, "email_logs"):
|
||||||
|
op.create_table(
|
||||||
|
"email_logs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("recipient_email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("subject", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="PENDING"),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("related_module", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("related_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("template_code", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("provider_message_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_email_logs_tenant_id", "email_logs", ["tenant_id"])
|
||||||
|
op.create_index("ix_email_logs_branch_id", "email_logs", ["branch_id"])
|
||||||
|
op.create_index("ix_email_logs_recipient_email", "email_logs", ["recipient_email"])
|
||||||
|
op.create_index("ix_email_logs_status", "email_logs", ["status"])
|
||||||
|
op.create_index("ix_email_logs_sent_at", "email_logs", ["sent_at"])
|
||||||
|
op.create_index("ix_email_logs_related_module", "email_logs", ["related_module"])
|
||||||
|
op.create_index("ix_email_logs_related_id", "email_logs", ["related_id"])
|
||||||
|
op.create_index("ix_email_logs_template_code", "email_logs", ["template_code"])
|
||||||
|
op.create_index("ix_email_logs_created_at_utc", "email_logs", ["created_at_utc"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _has_table(bind, "email_logs"):
|
||||||
|
op.drop_table("email_logs")
|
||||||
|
if _has_table(bind, "email_templates"):
|
||||||
|
op.drop_table("email_templates")
|
||||||
|
if _has_table(bind, "email_settings"):
|
||||||
|
op.drop_table("email_settings")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Phase 7S.1C email notification preferences
|
||||||
|
|
||||||
|
Revision ID: 20260609_phase_7s1c_email_preferences
|
||||||
|
Revises: 20260608_phase_7s1_email_notifications
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260609_phase_7s1c_email_preferences"
|
||||||
|
down_revision = "20260608_phase_7s1_email_notifications"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_NEW_COLUMNS: list[tuple[str, sa.Column]] = [
|
||||||
|
("send_invoice_emails", sa.Column("send_invoice_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_payment_emails", sa.Column("send_payment_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_client_emails", sa.Column("send_client_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_document_emails", sa.Column("send_document_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_consultant_emails", sa.Column("send_consultant_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_partner_review_emails", sa.Column("send_partner_review_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_leave_attendance_emails", sa.Column("send_leave_attendance_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
("send_online_payment_emails", sa.Column("send_online_payment_emails", sa.Boolean(), nullable=False, server_default=sa.true())),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return {col["name"] for col in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
existing = _existing_columns("email_settings")
|
||||||
|
with op.batch_alter_table("email_settings") as batch_op:
|
||||||
|
for name, column in _NEW_COLUMNS:
|
||||||
|
if name not in existing:
|
||||||
|
batch_op.add_column(column)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
existing = _existing_columns("email_settings")
|
||||||
|
with op.batch_alter_table("email_settings") as batch_op:
|
||||||
|
for name, _column in reversed(_NEW_COLUMNS):
|
||||||
|
if name in existing:
|
||||||
|
batch_op.drop_column(name)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Phase 7S.1E email queue and retry metadata
|
||||||
|
|
||||||
|
Revision ID: 20260610_phase_7s1e_email_queue
|
||||||
|
Revises: 20260609_phase_7s1c_email_preferences
|
||||||
|
Create Date: 2026-06-10
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260610_phase_7s1e_email_queue"
|
||||||
|
down_revision = "20260609_phase_7s1c_email_preferences"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_columns(table_name: str) -> set[str]:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
try:
|
||||||
|
return {col["name"] for col in inspector.get_columns(table_name)}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||||
|
if column.name not in _existing_columns(table_name):
|
||||||
|
op.add_column(table_name, column)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("queue_priority", sa.Integer(), nullable=False, server_default="100"))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("is_retryable", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("queued_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
_add_column_if_missing("email_logs", sa.Column("processing_started_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
existing_indexes = {idx["name"] for idx in sa.inspect(op.get_bind()).get_indexes("email_logs")}
|
||||||
|
for name, cols in {
|
||||||
|
"ix_email_logs_queue_priority": ["queue_priority"],
|
||||||
|
"ix_email_logs_is_retryable": ["is_retryable"],
|
||||||
|
"ix_email_logs_queued_at": ["queued_at"],
|
||||||
|
"ix_email_logs_next_retry_at": ["next_retry_at"],
|
||||||
|
"ix_email_logs_last_attempt_at": ["last_attempt_at"],
|
||||||
|
"ix_email_logs_processing_started_at": ["processing_started_at"],
|
||||||
|
}.items():
|
||||||
|
if name not in existing_indexes:
|
||||||
|
op.create_index(name, "email_logs", cols)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Safe downgrade for development only. SQLite may not support all drop-column
|
||||||
|
# operations depending on version, so production rollback should use backups.
|
||||||
|
for name in [
|
||||||
|
"ix_email_logs_processing_started_at",
|
||||||
|
"ix_email_logs_last_attempt_at",
|
||||||
|
"ix_email_logs_next_retry_at",
|
||||||
|
"ix_email_logs_queued_at",
|
||||||
|
"ix_email_logs_is_retryable",
|
||||||
|
"ix_email_logs_queue_priority",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
op.drop_index(name, table_name="email_logs")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for col in [
|
||||||
|
"processing_started_at",
|
||||||
|
"last_attempt_at",
|
||||||
|
"next_retry_at",
|
||||||
|
"queued_at",
|
||||||
|
"is_retryable",
|
||||||
|
"queue_priority",
|
||||||
|
"max_attempts",
|
||||||
|
"attempt_count",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
op.drop_column("email_logs", col)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Phase 7S.2 IMAP incoming email reading
|
||||||
|
|
||||||
|
Revision ID: 20260611_phase_7s2_imap_incoming
|
||||||
|
Revises: 20260610_phase_7s1e_email_queue
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260611_phase_7s2_imap_incoming"
|
||||||
|
down_revision = "20260610_phase_7s1e_email_queue"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
existing = {idx["name"] for idx in sa.inspect(op.get_bind()).get_indexes(table_name)} if _has_table(table_name) else set()
|
||||||
|
if index_name not in existing:
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("email_incoming_messages"):
|
||||||
|
op.create_table(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("mailbox_email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("folder_name", sa.String(length=120), nullable=False, server_default="INBOX"),
|
||||||
|
sa.Column("provider_uid", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("provider_message_id", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("sender_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("sender_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("recipient_emails", sa.Text(), nullable=True),
|
||||||
|
sa.Column("cc_emails", sa.Text(), nullable=True),
|
||||||
|
sa.Column("subject", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("body_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("body_html", sa.Text(), nullable=True),
|
||||||
|
sa.Column("raw_headers", sa.Text(), nullable=True),
|
||||||
|
sa.Column("received_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"),
|
||||||
|
sa.Column("matched_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("matched_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("matched_consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("related_module", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("related_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("has_attachments", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("attachment_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("fetched_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "mailbox_email", "folder_name", "provider_uid", name="uq_email_incoming_scope_mailbox_folder_uid"),
|
||||||
|
)
|
||||||
|
for name, cols in {
|
||||||
|
"ix_email_incoming_messages_tenant_id": ["tenant_id"],
|
||||||
|
"ix_email_incoming_messages_branch_id": ["branch_id"],
|
||||||
|
"ix_email_incoming_messages_mailbox_email": ["mailbox_email"],
|
||||||
|
"ix_email_incoming_messages_folder_name": ["folder_name"],
|
||||||
|
"ix_email_incoming_messages_provider_uid": ["provider_uid"],
|
||||||
|
"ix_email_incoming_messages_provider_message_id": ["provider_message_id"],
|
||||||
|
"ix_email_incoming_messages_sender_email": ["sender_email"],
|
||||||
|
"ix_email_incoming_messages_subject": ["subject"],
|
||||||
|
"ix_email_incoming_messages_received_at_utc": ["received_at_utc"],
|
||||||
|
"ix_email_incoming_messages_status": ["status"],
|
||||||
|
"ix_email_incoming_messages_matched_user_id": ["matched_user_id"],
|
||||||
|
"ix_email_incoming_messages_matched_client_id": ["matched_client_id"],
|
||||||
|
"ix_email_incoming_messages_matched_consultant_id": ["matched_consultant_id"],
|
||||||
|
"ix_email_incoming_messages_related_module": ["related_module"],
|
||||||
|
"ix_email_incoming_messages_related_id": ["related_id"],
|
||||||
|
"ix_email_incoming_messages_has_attachments": ["has_attachments"],
|
||||||
|
"ix_email_incoming_messages_fetched_at_utc": ["fetched_at_utc"],
|
||||||
|
}.items():
|
||||||
|
_create_index_if_missing("email_incoming_messages", name, cols)
|
||||||
|
|
||||||
|
if not _has_table("email_incoming_attachments"):
|
||||||
|
op.create_table(
|
||||||
|
"email_incoming_attachments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("incoming_message_id", sa.Integer(), sa.ForeignKey("email_incoming_messages.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("filename", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("content_type", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("storage_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
for name, cols in {
|
||||||
|
"ix_email_incoming_attachments_incoming_message_id": ["incoming_message_id"],
|
||||||
|
"ix_email_incoming_attachments_tenant_id": ["tenant_id"],
|
||||||
|
"ix_email_incoming_attachments_branch_id": ["branch_id"],
|
||||||
|
}.items():
|
||||||
|
_create_index_if_missing("email_incoming_attachments", name, cols)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
try:
|
||||||
|
op.drop_table("email_incoming_attachments")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
op.drop_table("email_incoming_messages")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Phase 7S.3 email-to-engagement mapping
|
||||||
|
|
||||||
|
Revision ID: 20260612_phase_7s3_email_mapping
|
||||||
|
Revises: 20260611_phase_7s2_imap_incoming
|
||||||
|
Create Date: 2026-06-12
|
||||||
|
|
||||||
|
SQLite-safe revision:
|
||||||
|
- Adds reference id columns as plain Integer columns instead of adding
|
||||||
|
ForeignKey constraints through ALTER TABLE, because SQLite cannot ALTER
|
||||||
|
constraints without batch copy/move migration.
|
||||||
|
- SQLAlchemy model relationships can still use these id columns for lookups.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260612_phase_7s3_email_mapping"
|
||||||
|
down_revision = "20260611_phase_7s2_imap_incoming"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return column_name in {col["name"] for col in _inspector().get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return
|
||||||
|
existing = {idx["name"] for idx in _inspector().get_indexes(table_name)}
|
||||||
|
if index_name not in existing:
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||||
|
if _has_table(table_name) and not _has_column(table_name, column.name):
|
||||||
|
op.add_column(table_name, column)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("email_incoming_messages"):
|
||||||
|
return
|
||||||
|
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("tracking_code", sa.String(length=80), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# SQLite does not support ALTER TABLE ADD CONSTRAINT for ForeignKey.
|
||||||
|
# Keep these as nullable integer reference columns. Application code and
|
||||||
|
# SQLAlchemy models can still resolve the linked records safely.
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("matched_engagement_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("matched_task_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("matched_invoice_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("mapping_status", sa.String(length=30), nullable=False, server_default="UNMAPPED"),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("mapping_notes", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"email_incoming_messages",
|
||||||
|
sa.Column("mapped_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, cols in {
|
||||||
|
"ix_email_incoming_messages_tracking_code": ["tracking_code"],
|
||||||
|
"ix_email_incoming_messages_matched_engagement_id": ["matched_engagement_id"],
|
||||||
|
"ix_email_incoming_messages_matched_task_id": ["matched_task_id"],
|
||||||
|
"ix_email_incoming_messages_matched_invoice_id": ["matched_invoice_id"],
|
||||||
|
"ix_email_incoming_messages_mapping_status": ["mapping_status"],
|
||||||
|
"ix_email_incoming_messages_mapped_at_utc": ["mapped_at_utc"],
|
||||||
|
}.items():
|
||||||
|
_create_index_if_missing("email_incoming_messages", name, cols)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Keep defensive because SQLite cannot reliably drop columns/constraints
|
||||||
|
# in all local development environments without a batch table rewrite.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Phase 7T.1 domain mapping table
|
||||||
|
|
||||||
|
Revision ID: 20260613_phase_7t1_domain_mapping
|
||||||
|
Revises: 20260612_phase_7s3_email_mapping
|
||||||
|
Create Date: 2026-06-13
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260613_phase_7t1_domain_mapping"
|
||||||
|
down_revision = "20260612_phase_7s3_email_mapping"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _table_exists("domain_mappings"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"domain_mappings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("domain_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("domain_type", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("consultant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("parent_tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="draft"),
|
||||||
|
sa.Column("verification_token", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("dns_txt_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("dns_txt_value", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("ssl_mode", sa.String(length=30), nullable=False, server_default="manual"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["consultant_id"], ["consultant_profiles.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["parent_tenant_id"], ["tenants.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("domain_name", name="uq_domain_mappings_domain_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_domain_mappings_domain_name", "domain_mappings", ["domain_name"])
|
||||||
|
op.create_index("ix_domain_mappings_domain_type", "domain_mappings", ["domain_type"])
|
||||||
|
op.create_index("ix_domain_mappings_status", "domain_mappings", ["status"])
|
||||||
|
op.create_index("ix_domain_mappings_tenant_id", "domain_mappings", ["tenant_id"])
|
||||||
|
op.create_index("ix_domain_mappings_branch_id", "domain_mappings", ["branch_id"])
|
||||||
|
op.create_index("ix_domain_mappings_consultant_id", "domain_mappings", ["consultant_id"])
|
||||||
|
op.create_index("ix_domain_mappings_parent_tenant_id", "domain_mappings", ["parent_tenant_id"])
|
||||||
|
op.create_index("ix_domain_mappings_is_primary", "domain_mappings", ["is_primary"])
|
||||||
|
op.create_index("ix_domain_mappings_is_verified", "domain_mappings", ["is_verified"])
|
||||||
|
op.create_index("ix_domain_mappings_verification_token", "domain_mappings", ["verification_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if not _table_exists("domain_mappings"):
|
||||||
|
return
|
||||||
|
op.drop_table("domain_mappings")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Phase 7T.9 SSL automation status fields
|
||||||
|
|
||||||
|
Revision ID: 20260614_phase_7t9_ssl_automation
|
||||||
|
Revises: 20260613_phase_7t1_domain_mapping
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260614_phase_7t9_ssl_automation"
|
||||||
|
down_revision = "20260613_phase_7t1_domain_mapping"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||||
|
if not _column_exists(table_name, column.name):
|
||||||
|
op.add_column(table_name, column)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _table_exists("domain_mappings"):
|
||||||
|
return
|
||||||
|
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_status", sa.String(length=30), nullable=False, server_default="not_checked"))
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_provider", sa.String(length=60), nullable=True))
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_last_checked_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_not_after_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_issuer", sa.String(length=255), nullable=True))
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_subject", sa.String(length=255), nullable=True))
|
||||||
|
_add_column_if_missing("domain_mappings", sa.Column("ssl_last_error", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
indexes = {idx["name"] for idx in inspector.get_indexes("domain_mappings")}
|
||||||
|
if "ix_domain_mappings_ssl_status" not in indexes:
|
||||||
|
op.create_index("ix_domain_mappings_ssl_status", "domain_mappings", ["ssl_status"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# SQLite-safe downgrade is intentionally conservative. Existing projects can keep
|
||||||
|
# these nullable/status fields without affecting domain resolution.
|
||||||
|
pass
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Phase 8A - task level documents and task template uploads
|
||||||
|
|
||||||
|
Revision ID: 20260615_phase_8a_task_docs
|
||||||
|
Revises: 20260614_phase_7t9_ssl_automation
|
||||||
|
Create Date: 2026-06-02
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260615_phase_8a_task_docs"
|
||||||
|
down_revision = "20260614_phase_7t9_ssl_automation"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect(bind):
|
||||||
|
return sa.inspect(bind)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(bind, table_name: str) -> bool:
|
||||||
|
return _inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(bind, table_name):
|
||||||
|
return False
|
||||||
|
return column_name in {col["name"] for col in _inspect(bind).get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(bind, table_name: str, index_name: str) -> bool:
|
||||||
|
if not _has_table(bind, table_name):
|
||||||
|
return False
|
||||||
|
return index_name in {ix["name"] for ix in _inspect(bind).get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _has_table(bind, "firm_task_document_requirements"):
|
||||||
|
op.create_table(
|
||||||
|
"firm_task_document_requirements",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("firm_task_template_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("document_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("document_type", sa.String(length=80), nullable=False, server_default="GENERAL"),
|
||||||
|
sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("allowed_file_types", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("instructions", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_firm_task_doc_req_tenant_id", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], name="fk_firm_task_doc_req_service_catalogue_id", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], name="fk_firm_task_doc_req_firm_task_template_id", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_firm_task_doc_req_created_by_user_id"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name="fk_firm_task_doc_req_updated_by_user_id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "firm_task_template_id", "document_name", name="uq_firm_task_document_requirements_name"),
|
||||||
|
)
|
||||||
|
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_tenant_id"):
|
||||||
|
op.create_index("ix_firm_task_document_requirements_tenant_id", "firm_task_document_requirements", ["tenant_id"])
|
||||||
|
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_service_catalogue_id"):
|
||||||
|
op.create_index("ix_firm_task_document_requirements_service_catalogue_id", "firm_task_document_requirements", ["service_catalogue_id"])
|
||||||
|
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_firm_task_template_id"):
|
||||||
|
op.create_index("ix_firm_task_document_requirements_firm_task_template_id", "firm_task_document_requirements", ["firm_task_template_id"])
|
||||||
|
if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_document_type"):
|
||||||
|
op.create_index("ix_firm_task_document_requirements_document_type", "firm_task_document_requirements", ["document_type"])
|
||||||
|
|
||||||
|
if not _has_table(bind, "firm_task_document_templates"):
|
||||||
|
op.create_table(
|
||||||
|
"firm_task_document_templates",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_catalogue_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("firm_task_template_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("template_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("template_category", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("original_filename", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("stored_filename", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(length=150), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("local_relative_path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_firm_task_doc_tpl_tenant_id", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], name="fk_firm_task_doc_tpl_service_catalogue_id", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], name="fk_firm_task_doc_tpl_firm_task_template_id", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], name="fk_firm_task_doc_tpl_uploaded_by_user_id"),
|
||||||
|
)
|
||||||
|
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_tenant_id"):
|
||||||
|
op.create_index("ix_firm_task_document_templates_tenant_id", "firm_task_document_templates", ["tenant_id"])
|
||||||
|
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_service_catalogue_id"):
|
||||||
|
op.create_index("ix_firm_task_document_templates_service_catalogue_id", "firm_task_document_templates", ["service_catalogue_id"])
|
||||||
|
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_firm_task_template_id"):
|
||||||
|
op.create_index("ix_firm_task_document_templates_firm_task_template_id", "firm_task_document_templates", ["firm_task_template_id"])
|
||||||
|
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_template_category"):
|
||||||
|
op.create_index("ix_firm_task_document_templates_template_category", "firm_task_document_templates", ["template_category"])
|
||||||
|
if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_is_active"):
|
||||||
|
op.create_index("ix_firm_task_document_templates_is_active", "firm_task_document_templates", ["is_active"])
|
||||||
|
|
||||||
|
# SQLite-safe: add nullable columns only. Do not create batch foreign-key constraints here.
|
||||||
|
# SQLAlchemy relationships in the app still work through these *_id columns.
|
||||||
|
if _has_table(bind, "engagement_documents"):
|
||||||
|
with op.batch_alter_table("engagement_documents") as batch:
|
||||||
|
if not _has_column(bind, "engagement_documents", "task_instance_id"):
|
||||||
|
batch.add_column(sa.Column("task_instance_id", sa.Integer(), nullable=True))
|
||||||
|
if not _has_column(bind, "engagement_documents", "document_requirement_id"):
|
||||||
|
batch.add_column(sa.Column("document_requirement_id", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
if not _has_index(bind, "engagement_documents", "ix_engagement_documents_task_instance_id"):
|
||||||
|
op.create_index("ix_engagement_documents_task_instance_id", "engagement_documents", ["task_instance_id"])
|
||||||
|
if not _has_index(bind, "engagement_documents", "ix_engagement_documents_document_requirement_id"):
|
||||||
|
op.create_index("ix_engagement_documents_document_requirement_id", "engagement_documents", ["document_requirement_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if _has_table(bind, "engagement_documents"):
|
||||||
|
if _has_index(bind, "engagement_documents", "ix_engagement_documents_document_requirement_id"):
|
||||||
|
op.drop_index("ix_engagement_documents_document_requirement_id", table_name="engagement_documents")
|
||||||
|
if _has_index(bind, "engagement_documents", "ix_engagement_documents_task_instance_id"):
|
||||||
|
op.drop_index("ix_engagement_documents_task_instance_id", table_name="engagement_documents")
|
||||||
|
with op.batch_alter_table("engagement_documents") as batch:
|
||||||
|
if _has_column(bind, "engagement_documents", "document_requirement_id"):
|
||||||
|
batch.drop_column("document_requirement_id")
|
||||||
|
if _has_column(bind, "engagement_documents", "task_instance_id"):
|
||||||
|
batch.drop_column("task_instance_id")
|
||||||
|
|
||||||
|
if _has_table(bind, "firm_task_document_templates"):
|
||||||
|
op.drop_table("firm_task_document_templates")
|
||||||
|
if _has_table(bind, "firm_task_document_requirements"):
|
||||||
|
op.drop_table("firm_task_document_requirements")
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Phase 8B - Notice and Case Management
|
||||||
|
|
||||||
|
Revision ID: 20260616_phase_8b_notice_cases
|
||||||
|
Revises: 20260615_phase_8a_task_docs
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260616_phase_8b_notice_cases"
|
||||||
|
down_revision = "20260615_phase_8a_task_docs"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notice_cases",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("case_code", sa.String(80), nullable=False),
|
||||||
|
sa.Column("department", sa.String(40), nullable=False),
|
||||||
|
sa.Column("case_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("reference_no", sa.String(150), nullable=True),
|
||||||
|
sa.Column("din_ack_no", sa.String(150), nullable=True),
|
||||||
|
sa.Column("notice_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("financial_year", sa.String(9), nullable=True),
|
||||||
|
sa.Column("assessment_year", sa.String(9), nullable=True),
|
||||||
|
sa.Column("period_label", sa.String(60), nullable=True),
|
||||||
|
sa.Column("status", sa.String(40), nullable=False, server_default="open"),
|
||||||
|
sa.Column("priority", sa.String(20), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("issue_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("remarks", sa.Text(), nullable=True),
|
||||||
|
sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_manager_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assigned_staff_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("archived_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("archived_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_cases_tenant", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_cases_branch", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], name="fk_notice_cases_client", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], name="fk_notice_cases_engagement", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"], name="fk_notice_cases_partner", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_manager_user_id"], ["users.id"], name="fk_notice_cases_manager", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_staff_user_id"], ["users.id"], name="fk_notice_cases_staff", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["archived_by_user_id"], ["users.id"], name="fk_notice_cases_archived_by", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_cases_created_by", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name="fk_notice_cases_updated_by", ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "case_code", name="uq_notice_cases_tenant_code"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id","branch_id","client_id","engagement_id","case_code","department","case_type","reference_no","din_ack_no","notice_date","due_date","financial_year","assessment_year","period_label","status","priority","assigned_partner_user_id","assigned_manager_user_id","assigned_staff_user_id","is_archived"]:
|
||||||
|
op.create_index(f"ix_notice_cases_{col}", "notice_cases", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notice_case_events",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("case_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(60), nullable=False),
|
||||||
|
sa.Column("event_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=False),
|
||||||
|
sa.Column("next_due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_events_tenant", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_events_branch", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_events_case", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_events_created_by", ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id","branch_id","case_id","event_type","event_date","next_due_date","created_by_user_id"]:
|
||||||
|
op.create_index(f"ix_notice_case_events_{col}", "notice_case_events", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notice_case_hearings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("case_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("hearing_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("hearing_time", sa.String(20), nullable=True),
|
||||||
|
sa.Column("venue_or_mode", sa.String(200), nullable=True),
|
||||||
|
sa.Column("officer_name", sa.String(150), nullable=True),
|
||||||
|
sa.Column("agenda", sa.Text(), nullable=True),
|
||||||
|
sa.Column("outcome", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="scheduled"),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_hearings_tenant", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_hearings_branch", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_hearings_case", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_hearings_created_by", ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id","branch_id","case_id","hearing_date","status","created_by_user_id"]:
|
||||||
|
op.create_index(f"ix_notice_case_hearings_{col}", "notice_case_hearings", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notice_case_orders",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("case_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("order_type", sa.String(60), nullable=False),
|
||||||
|
sa.Column("order_no", sa.String(150), nullable=True),
|
||||||
|
sa.Column("order_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("demand_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("tax_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("interest_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("penalty_amount", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("appeal_due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("appeal_filed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_orders_tenant", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_orders_branch", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_orders_case", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_orders_created_by", ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id","branch_id","case_id","order_type","order_no","order_date","appeal_due_date","appeal_filed","created_by_user_id"]:
|
||||||
|
op.create_index(f"ix_notice_case_orders_{col}", "notice_case_orders", [col])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notice_case_documents",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("case_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("document_type", sa.String(80), nullable=False, server_default="GENERAL"),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("stored_filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(150), nullable=True),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("local_relative_path", sa.String(1000), nullable=False),
|
||||||
|
sa.Column("version_no", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("deleted_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_documents_tenant", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_documents_branch", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_documents_case", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["event_id"], ["notice_case_events.id"], name="fk_notice_case_documents_event", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["deleted_by_user_id"], ["users.id"], name="fk_notice_case_documents_deleted_by", ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], name="fk_notice_case_documents_uploaded_by", ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
for col in ["tenant_id","branch_id","case_id","event_id","document_type","status","is_deleted","uploaded_by_user_id"]:
|
||||||
|
op.create_index(f"ix_notice_case_documents_{col}", "notice_case_documents", [col])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("notice_case_documents")
|
||||||
|
op.drop_table("notice_case_orders")
|
||||||
|
op.drop_table("notice_case_hearings")
|
||||||
|
op.drop_table("notice_case_events")
|
||||||
|
op.drop_table("notice_cases")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Phase v2.0.4-A - Financial Year Master and Active FY Context
|
||||||
|
|
||||||
|
Revision ID: 20260617_phase_204a_financial_year
|
||||||
|
Revises: 20260616_phase_8b_notice_cases
|
||||||
|
Create Date: 2026-06-17
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260617_phase_204a_financial_year"
|
||||||
|
down_revision = "20260616_phase_8b_notice_cases"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"financial_years",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("year_code", sa.String(9), nullable=False),
|
||||||
|
sa.Column("assessment_year", sa.String(9), nullable=False),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("is_current", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("locked_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_financial_years_tenant", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["locked_by_user_id"], ["users.id"], name="fk_financial_years_locked_by", ondelete="SET NULL"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "year_code", name="uq_financial_year_tenant_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_financial_years_tenant_id", "financial_years", ["tenant_id"])
|
||||||
|
op.create_index("ix_financial_years_year_code", "financial_years", ["year_code"])
|
||||||
|
op.create_index("ix_financial_years_assessment_year", "financial_years", ["assessment_year"])
|
||||||
|
op.create_index("ix_financial_years_is_current", "financial_years", ["is_current"])
|
||||||
|
op.create_index("ix_financial_years_is_locked", "financial_years", ["is_locked"])
|
||||||
|
op.create_index("ix_financial_years_locked_by_user_id", "financial_years", ["locked_by_user_id"])
|
||||||
|
|
||||||
|
# Seed the default FY for every existing tenant so the selector works immediately
|
||||||
|
# after upgrade. Further years can be created from System Settings.
|
||||||
|
conn = op.get_bind()
|
||||||
|
tenants = conn.execute(sa.text("SELECT id FROM tenants")).fetchall()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for row in tenants:
|
||||||
|
tenant_id = row[0]
|
||||||
|
exists = conn.execute(
|
||||||
|
sa.text("SELECT id FROM financial_years WHERE tenant_id = :tenant_id AND year_code = :year_code"),
|
||||||
|
{"tenant_id": tenant_id, "year_code": "2025-26"},
|
||||||
|
).first()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO financial_years
|
||||||
|
(tenant_id, year_code, assessment_year, start_date, end_date, is_current, is_locked, created_at_utc, updated_at_utc)
|
||||||
|
VALUES
|
||||||
|
(:tenant_id, :year_code, :assessment_year, :start_date, :end_date, :is_current, :is_locked, :created_at_utc, :updated_at_utc)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"year_code": "2025-26",
|
||||||
|
"assessment_year": "2026-27",
|
||||||
|
"start_date": date(2025, 4, 1),
|
||||||
|
"end_date": date(2026, 3, 31),
|
||||||
|
"is_current": True,
|
||||||
|
"is_locked": False,
|
||||||
|
"created_at_utc": now,
|
||||||
|
"updated_at_utc": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_financial_years_locked_by_user_id", table_name="financial_years")
|
||||||
|
op.drop_index("ix_financial_years_is_locked", table_name="financial_years")
|
||||||
|
op.drop_index("ix_financial_years_is_current", table_name="financial_years")
|
||||||
|
op.drop_index("ix_financial_years_assessment_year", table_name="financial_years")
|
||||||
|
op.drop_index("ix_financial_years_year_code", table_name="financial_years")
|
||||||
|
op.drop_index("ix_financial_years_tenant_id", table_name="financial_years")
|
||||||
|
op.drop_table("financial_years")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Phase v2.0.4-D - Billing financial year isolation
|
||||||
|
|
||||||
|
Revision ID: 20260618_phase_204d_billing_fy
|
||||||
|
Revises: 20260617_phase_204a_financial_year
|
||||||
|
Create Date: 2026-06-18
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260618_phase_204d_billing_fy"
|
||||||
|
down_revision = "20260617_phase_204a_financial_year"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fy_label(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
value = value.date()
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = date.fromisoformat(value[:10])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, date):
|
||||||
|
return None
|
||||||
|
start_year = value.year if value.month >= 4 else value.year - 1
|
||||||
|
return f"{start_year}-{str(start_year + 1)[-2:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(conn, table_name: str) -> bool:
|
||||||
|
return sa.inspect(conn).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(conn, table_name):
|
||||||
|
return False
|
||||||
|
return any(col["name"] == column_name for col in sa.inspect(conn).get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(conn, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||||
|
if not _has_table(conn, table_name):
|
||||||
|
return
|
||||||
|
existing = {idx["name"] for idx in sa.inspect(conn).get_indexes(table_name)}
|
||||||
|
if index_name not in existing:
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_index_if_exists(conn, index_name: str, table_name: str) -> None:
|
||||||
|
if not _has_table(conn, table_name):
|
||||||
|
return
|
||||||
|
existing = {idx["name"] for idx in sa.inspect(conn).get_indexes(table_name)}
|
||||||
|
if index_name in existing:
|
||||||
|
op.drop_index(index_name, table_name=table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if _has_table(conn, "billing_invoice_generation_batches") and not _has_column(conn, "billing_invoice_generation_batches", "financial_year"):
|
||||||
|
op.add_column("billing_invoice_generation_batches", sa.Column("financial_year", sa.String(length=20), nullable=True))
|
||||||
|
rows = conn.execute(sa.text("SELECT id, billing_period_from FROM billing_invoice_generation_batches")).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
fy = _fy_label(row["billing_period_from"])
|
||||||
|
if fy:
|
||||||
|
conn.execute(sa.text("UPDATE billing_invoice_generation_batches SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]})
|
||||||
|
_create_index_if_missing(conn, "ix_billing_invoice_generation_batches_financial_year", "billing_invoice_generation_batches", ["financial_year"])
|
||||||
|
|
||||||
|
if _has_table(conn, "billing_invoices") and not _has_column(conn, "billing_invoices", "financial_year"):
|
||||||
|
op.add_column("billing_invoices", sa.Column("financial_year", sa.String(length=20), nullable=True))
|
||||||
|
rows = conn.execute(sa.text("SELECT id, billing_period_from, invoice_date FROM billing_invoices")).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
fy = _fy_label(row["billing_period_from"]) or _fy_label(row["invoice_date"])
|
||||||
|
if fy:
|
||||||
|
conn.execute(sa.text("UPDATE billing_invoices SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]})
|
||||||
|
_create_index_if_missing(conn, "ix_billing_invoices_financial_year", "billing_invoices", ["financial_year"])
|
||||||
|
|
||||||
|
if _has_table(conn, "billing_payments") and not _has_column(conn, "billing_payments", "financial_year"):
|
||||||
|
op.add_column("billing_payments", sa.Column("financial_year", sa.String(length=20), nullable=True))
|
||||||
|
rows = conn.execute(sa.text("SELECT id, payment_date FROM billing_payments")).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
fy = _fy_label(row["payment_date"])
|
||||||
|
if fy:
|
||||||
|
conn.execute(sa.text("UPDATE billing_payments SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]})
|
||||||
|
_create_index_if_missing(conn, "ix_billing_payments_financial_year", "billing_payments", ["financial_year"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if _has_column(conn, "billing_payments", "financial_year"):
|
||||||
|
_drop_index_if_exists(conn, "ix_billing_payments_financial_year", "billing_payments")
|
||||||
|
op.drop_column("billing_payments", "financial_year")
|
||||||
|
|
||||||
|
if _has_column(conn, "billing_invoices", "financial_year"):
|
||||||
|
_drop_index_if_exists(conn, "ix_billing_invoices_financial_year", "billing_invoices")
|
||||||
|
op.drop_column("billing_invoices", "financial_year")
|
||||||
|
|
||||||
|
if _has_column(conn, "billing_invoice_generation_batches", "financial_year"):
|
||||||
|
_drop_index_if_exists(conn, "ix_billing_invoice_generation_batches_financial_year", "billing_invoice_generation_batches")
|
||||||
|
op.drop_column("billing_invoice_generation_batches", "financial_year")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Phase v2.0.4-E - Year Lock and Backup Export
|
||||||
|
|
||||||
|
Revision ID: 20260619_phase_204e_year_lock_backup
|
||||||
|
Revises: 20260618_phase_204d_billing_fy
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260619_phase_204e_year_lock_backup"
|
||||||
|
down_revision = "20260618_phase_204d_billing_fy"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"year_backup_exports",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("financial_year_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("year_code", sa.String(length=9), nullable=False),
|
||||||
|
sa.Column("assessment_year", sa.String(length=9), nullable=True),
|
||||||
|
sa.Column("export_status", sa.String(length=30), nullable=False, server_default="completed"),
|
||||||
|
sa.Column("export_file_path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("manifest_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("generated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("generated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["financial_year_id"], ["financial_years.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["generated_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_year_backup_exports_tenant_id", "year_backup_exports", ["tenant_id"])
|
||||||
|
op.create_index("ix_year_backup_exports_financial_year_id", "year_backup_exports", ["financial_year_id"])
|
||||||
|
op.create_index("ix_year_backup_exports_year_code", "year_backup_exports", ["year_code"])
|
||||||
|
op.create_index("ix_year_backup_exports_assessment_year", "year_backup_exports", ["assessment_year"])
|
||||||
|
op.create_index("ix_year_backup_exports_export_status", "year_backup_exports", ["export_status"])
|
||||||
|
op.create_index("ix_year_backup_exports_generated_by_user_id", "year_backup_exports", ["generated_by_user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_year_backup_exports_generated_by_user_id", table_name="year_backup_exports")
|
||||||
|
op.drop_index("ix_year_backup_exports_export_status", table_name="year_backup_exports")
|
||||||
|
op.drop_index("ix_year_backup_exports_assessment_year", table_name="year_backup_exports")
|
||||||
|
op.drop_index("ix_year_backup_exports_year_code", table_name="year_backup_exports")
|
||||||
|
op.drop_index("ix_year_backup_exports_financial_year_id", table_name="year_backup_exports")
|
||||||
|
op.drop_index("ix_year_backup_exports_tenant_id", table_name="year_backup_exports")
|
||||||
|
op.drop_table("year_backup_exports")
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
|
||||||
|
"""reconcile common tables and clients safely
|
||||||
|
|
||||||
|
Revision ID: 7f1c9b2d4a10
|
||||||
|
Revises: 3d5ce4b11767
|
||||||
|
Create Date: 2026-04-05 14:30:00.000000
|
||||||
|
|
||||||
|
NOTE:
|
||||||
|
- This is a manual repair/reconciliation migration.
|
||||||
|
- It is intentionally written to CREATE missing tables/indexes only.
|
||||||
|
- It does NOT drop existing tables.
|
||||||
|
- It is safe for the current state where revision 3d5ce4b11767 has already been applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "7f1c9b2d4a10"
|
||||||
|
down_revision: Union[str, None] = "940fb75ddce6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
bind = op.get_bind()
|
||||||
|
return sa.inspect(bind)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in _inspector().get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
indexes = _inspector().get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(ix.get("name") == index_name for ix in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None:
|
||||||
|
if not _has_index(table_name, index_name):
|
||||||
|
op.create_index(index_name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("audit_logs"):
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("actor_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("actor_tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("actor_branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("entity_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("entity_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("target_tenant_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("target_branch_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("ip_address", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("details_json", sa.Text(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_action", ["action"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_actor_branch_id", ["actor_branch_id"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_actor_tenant_id", ["actor_tenant_id"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_actor_user_id", ["actor_user_id"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_created_at_utc", ["created_at_utc"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_entity_type", ["entity_type"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_status", ["status"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_target_branch_id", ["target_branch_id"])
|
||||||
|
_create_index_if_missing("audit_logs", "ix_audit_logs_target_tenant_id", ["target_tenant_id"])
|
||||||
|
|
||||||
|
if not _has_table("services"):
|
||||||
|
op.create_table(
|
||||||
|
"services",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("service_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_client_requestable", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "branch_id", "service_code", name="uq_services_tenant_branch_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("services", "ix_services_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing("services", "ix_services_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing("services", "ix_services_service_code", ["service_code"])
|
||||||
|
_create_index_if_missing("services", "ix_services_service_name", ["service_name"])
|
||||||
|
|
||||||
|
if not _has_table("service_task_templates"):
|
||||||
|
op.create_table(
|
||||||
|
"service_task_templates",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("service_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("task_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("sequence_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("default_role_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("is_mandatory", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("requires_review", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["service_id"], ["services.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("service_id", "sequence_no", name="uq_service_task_templates_sequence"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("service_task_templates", "ix_service_task_templates_service_id", ["service_id"])
|
||||||
|
|
||||||
|
if not _has_table("invite_tokens"):
|
||||||
|
op.create_table(
|
||||||
|
"invite_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("expires_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("used_at_utc", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("token_hash", name="uq_invite_token_hash"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("invite_tokens", "ix_invite_tokens_user_id", ["user_id"])
|
||||||
|
_create_index_if_missing("invite_tokens", "ix_invite_tokens_token_hash", ["token_hash"])
|
||||||
|
|
||||||
|
if not _has_table("password_reset_tokens"):
|
||||||
|
op.create_table(
|
||||||
|
"password_reset_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("expires_at_utc", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("used_at_utc", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("password_reset_tokens", "ix_password_reset_tokens_user_id", ["user_id"])
|
||||||
|
_create_index_if_missing("password_reset_tokens", "ix_password_reset_tokens_token_hash", ["token_hash"])
|
||||||
|
|
||||||
|
if not _has_table("clients"):
|
||||||
|
op.create_table(
|
||||||
|
"clients",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("partner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("client_code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("client_name", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("trade_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("client_type", sa.String(length=100), nullable=False, server_default="Other"),
|
||||||
|
sa.Column("pan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("gstin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("tan", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("cin_llpin", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("msme_no", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("iec_code", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("contact_person_name", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("contact_person_designation", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("alternate_mobile", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("alternate_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("address_line_1", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("address_line_2", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("city", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("state", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("pincode", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("country", sa.String(length=100), nullable=True, server_default="India"),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("client_category", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("risk_category", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("onboarding_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("closing_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("gst_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("income_tax_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("tds_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("roc_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("audit_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("pf_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("esi_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("professional_tax_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("payroll_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("msme_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("import_export_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("archived_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["partner_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]),
|
||||||
|
sa.UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("clients", "ix_clients_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_partner_id", ["partner_id"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_client_code", ["client_code"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_client_name", ["client_name"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_pan", ["pan"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_gstin", ["gstin"])
|
||||||
|
_create_index_if_missing("clients", "ix_clients_status", ["status"])
|
||||||
|
|
||||||
|
if not _has_table("client_audit_logs"):
|
||||||
|
op.create_table(
|
||||||
|
"client_audit_logs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("branch_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("summary", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("payload_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_client_id", ["client_id"])
|
||||||
|
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_tenant_id", ["tenant_id"])
|
||||||
|
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_branch_id", ["branch_id"])
|
||||||
|
_create_index_if_missing("client_audit_logs", "ix_client_audit_logs_action", ["action"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Intentionally conservative:
|
||||||
|
# only remove tables introduced by this repair migration that are not baseline-owned.
|
||||||
|
# We do NOT drop services/auth-flow/audit tables here to avoid accidental data loss.
|
||||||
|
if _has_table("client_audit_logs"):
|
||||||
|
op.drop_table("client_audit_logs")
|
||||||
|
if _has_table("clients"):
|
||||||
|
op.drop_table("clients")
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""0001 baseline common
|
||||||
|
|
||||||
|
Revision ID: 940fb75ddce6
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-15 19:52:18.618166
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '940fb75ddce6'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('login_attempts',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('key', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('attempts', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('locked_until_utc', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at_utc', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('key', name='uq_login_attempt_key')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('login_attempts', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_login_attempts_key'), ['key'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('permissions',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code', name='uq_permission_code')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('permissions', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_permissions_code'), ['code'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('roles',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name', name='uq_role_name')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('roles', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_roles_name'), ['name'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('tenants',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('default_timezone', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('default_session_duration_minutes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('default_otp_required_roles_csv', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('default_storage_mode', sa.String(length=20), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('tenants', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_tenants_code'), ['code'], unique=True)
|
||||||
|
|
||||||
|
op.create_table('branches',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('timezone', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('office_start_time', sa.Time(), nullable=True),
|
||||||
|
sa.Column('office_end_time', sa.Time(), nullable=True),
|
||||||
|
sa.Column('allow_login', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('allow_new_assignments', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_head_office', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('smtp_host', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('smtp_port', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('smtp_username', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('smtp_password', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('smtp_use_tls', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('local_storage_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'code', name='uq_branch_tenant_code')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('branches', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_branches_code'), ['code'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_branches_tenant_id'), ['tenant_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('role_permissions',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('permission_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('role_id', 'permission_id', name='uq_role_perm')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('role_permissions', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_role_permissions_permission_id'), ['permission_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_role_permissions_role_id'), ['role_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('branch_settings',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('branch_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('address_line1', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('address_line2', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('city', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('state', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('pin_code', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('gstin', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('pan', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('letterhead_logo_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('letterhead_signature_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('letterhead_stamp_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('geo_address', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('latitude', sa.Float(), nullable=True),
|
||||||
|
sa.Column('longitude', sa.Float(), nullable=True),
|
||||||
|
sa.Column('working_days_csv', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('holidays_json', sa.Text(), nullable=False),
|
||||||
|
sa.Column('timezone_locked', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('email_from_name', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('email_from_email', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('email_reply_to', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('default_cc_csv', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('default_bcc_csv', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('email_signature_html', sa.Text(), nullable=True),
|
||||||
|
sa.Column('storage_mode', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('folder_template', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('max_file_mb', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('allowed_ext_csv', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('retention_years', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('otp_required_roles_csv', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('session_duration_minutes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('lockout_attempts', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('lockout_minutes', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['branch_id'], ['branches.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('branch_id', name='uq_branch_settings_branch')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('branch_settings', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_branch_settings_branch_id'), ['branch_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('full_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('branch_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('allow_login', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_locked', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('locked_at_utc', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('must_change_password', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('password_changed_at_utc', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['branch_id'], ['branches.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email', name='uq_user_email')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_users_branch_id'), ['branch_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_users_tenant_id'), ['tenant_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('refresh_tokens',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('created_at_utc', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at_utc', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('revoked', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('rotated_from_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token_hash', name='uq_refresh_token_hash')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_refresh_tokens_token_hash'), ['token_hash'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_refresh_tokens_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('user_roles',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'role_id', name='uq_user_role')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('user_roles', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_user_roles_role_id'), ['role_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_user_roles_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user_roles', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_user_roles_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_user_roles_role_id'))
|
||||||
|
|
||||||
|
op.drop_table('user_roles')
|
||||||
|
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_refresh_tokens_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_refresh_tokens_token_hash'))
|
||||||
|
|
||||||
|
op.drop_table('refresh_tokens')
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_users_tenant_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_users_email'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_users_branch_id'))
|
||||||
|
|
||||||
|
op.drop_table('users')
|
||||||
|
with op.batch_alter_table('branch_settings', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_branch_settings_branch_id'))
|
||||||
|
|
||||||
|
op.drop_table('branch_settings')
|
||||||
|
with op.batch_alter_table('role_permissions', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_role_permissions_role_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_role_permissions_permission_id'))
|
||||||
|
|
||||||
|
op.drop_table('role_permissions')
|
||||||
|
with op.batch_alter_table('branches', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_branches_tenant_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_branches_code'))
|
||||||
|
|
||||||
|
op.drop_table('branches')
|
||||||
|
with op.batch_alter_table('tenants', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_tenants_code'))
|
||||||
|
|
||||||
|
op.drop_table('tenants')
|
||||||
|
with op.batch_alter_table('roles', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_roles_name'))
|
||||||
|
|
||||||
|
op.drop_table('roles')
|
||||||
|
with op.batch_alter_table('permissions', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_permissions_code'))
|
||||||
|
|
||||||
|
op.drop_table('permissions')
|
||||||
|
with op.batch_alter_table('login_attempts', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_login_attempts_key'))
|
||||||
|
|
||||||
|
op.drop_table('login_attempts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge ds5 and ds6 document heads
|
||||||
|
|
||||||
|
Revision ID: af48d99d7321
|
||||||
|
Revises: 20260518_ds6_permanent_client_document_vault, 20260528_phase_ds5_secure_download_streaming
|
||||||
|
Create Date: 2026-05-18 20:41:43.386276
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'af48d99d7321'
|
||||||
|
down_revision: Union[str, None] = ('20260518_ds6_permanent_client_document_vault', '20260528_phase_ds5_secure_download_streaming')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.modules.clients.api import router as clients_api
|
||||||
|
from app.modules.core.iam.api import router as users_api
|
||||||
|
from app.modules.core.iam.auth_api import router as auth_api
|
||||||
|
from app.modules.core.rbac.api import router as rbac_api
|
||||||
|
from app.modules.core.tenancy.api import router as tenancy_api
|
||||||
|
from app.modules.system.health.api import router as health_api
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health_api)
|
||||||
|
api_router.include_router(tenancy_api)
|
||||||
|
api_router.include_router(rbac_api)
|
||||||
|
api_router.include_router(auth_api)
|
||||||
|
api_router.include_router(users_api)
|
||||||
|
api_router.include_router(clients_api)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
from app.core.db.urls import get_common_db_url
|
||||||
|
|
||||||
|
class CommonBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
CommonEngine = create_engine(get_common_db_url(), pool_pre_ping=True, future=True)
|
||||||
|
CommonSessionLocal = sessionmaker(bind=CommonEngine, autocommit=False, autoflush=False, future=True)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from typing import Generator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
|
||||||
|
def get_common_db() -> Generator[Session, None, None]:
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy.engine import URL
|
||||||
|
|
||||||
|
from app.core.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_url(path: str) -> str:
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
return f"sqlite+pysqlite:///{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def postgres_url(user: str, password: str, host: str, port: int, db: str) -> str:
|
||||||
|
return URL.create(
|
||||||
|
drivername="postgresql+psycopg",
|
||||||
|
username=user,
|
||||||
|
password=password,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
database=db,
|
||||||
|
).render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_common_db_url() -> str:
|
||||||
|
s = get_settings()
|
||||||
|
if s.DB_BACKEND.lower() == "sqlite":
|
||||||
|
return sqlite_url(s.SQLITE_COMMON_PATH)
|
||||||
|
return postgres_url(s.PG_USER, s.PG_PASSWORD, s.PG_HOST, s.PG_PORT, s.PG_DB_COMMON)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
|
from app.core.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
_CONTEXT_SECRET_HEADER = "X-AuditFirm-Context-Secret"
|
||||||
|
_TENANT_HEADER = "X-Tenant-Code"
|
||||||
|
_BRANCH_HEADER = "X-Branch-Code"
|
||||||
|
_YEAR_HEADER = "X-Year-Code"
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_values(value: str | None) -> list[str]:
|
||||||
|
return [item.strip() for item in (value or "").split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_env(value: str | None) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _host_matches_trusted_entry(client_host: str, trusted_entry: str) -> bool:
|
||||||
|
"""Return True when client_host matches a trusted host/IP/CIDR entry.
|
||||||
|
|
||||||
|
Deliberately does not support '*' wildcard. For Docker/Coolify internal
|
||||||
|
networks, use an explicit CIDR such as 172.16.0.0/12.
|
||||||
|
"""
|
||||||
|
client_host = (client_host or "").strip().lower()
|
||||||
|
trusted_entry = (trusted_entry or "").strip().lower()
|
||||||
|
if not client_host or not trusted_entry:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if client_host == trusted_entry:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_ip = ip_address(client_host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "/" in trusted_entry:
|
||||||
|
return client_ip in ip_network(trusted_entry, strict=False)
|
||||||
|
return client_ip == ip_address(trusted_entry)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_session_int(value):
|
||||||
|
if value in (None, "", 0, "0"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ContextResolveMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
super().__init__(app)
|
||||||
|
self.s = get_settings()
|
||||||
|
|
||||||
|
def _context_headers_are_trusted(self, request: Request) -> bool:
|
||||||
|
"""Permit context headers only from trusted internal callers.
|
||||||
|
|
||||||
|
Public users must not be able to switch tenant/branch/FY by adding
|
||||||
|
X-Tenant-Code, X-Branch-Code or X-Year-Code headers. The production-safe
|
||||||
|
default is TRUST_CONTEXT_HEADERS=false.
|
||||||
|
"""
|
||||||
|
if not bool(getattr(self.s, "TRUST_CONTEXT_HEADERS", False)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
required_secret = (getattr(self.s, "CONTEXT_HEADER_SECRET", "") or "").strip()
|
||||||
|
if required_secret:
|
||||||
|
supplied_secret = (request.headers.get(_CONTEXT_SECRET_HEADER) or "").strip()
|
||||||
|
if supplied_secret != required_secret:
|
||||||
|
return False
|
||||||
|
elif _safe_env(getattr(self.s, "ENV", "")) in {"prod", "production"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client_host = request.client.host if request.client else ""
|
||||||
|
trusted_entries = _csv_values(getattr(self.s, "TRUST_CONTEXT_HEADER_HOSTS", ""))
|
||||||
|
return any(_host_matches_trusted_entry(client_host, item) for item in trusted_entries)
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Trusted production context priority:
|
||||||
|
# 1) Authenticated UI session selected tenant/branch/FY.
|
||||||
|
# 2) Domain resolver mapping for pre-login/domain-routed requests.
|
||||||
|
# 3) Trusted internal headers only when explicitly enabled with secret/host.
|
||||||
|
# 4) Application defaults.
|
||||||
|
session = request.scope.get("session") or {}
|
||||||
|
trust_headers = self._context_headers_are_trusted(request)
|
||||||
|
|
||||||
|
session_tenant_id = _normalise_session_int(session.get("active_tenant_id") or session.get("tenant_id"))
|
||||||
|
session_branch_id = _normalise_session_int(session.get("active_branch_id") or session.get("branch_id"))
|
||||||
|
|
||||||
|
session_tenant_code = (session.get("active_tenant_code") or session.get("tenant_code") or "").strip() or None
|
||||||
|
session_branch_code = (session.get("active_branch_code") or session.get("branch_code") or "").strip() or None
|
||||||
|
|
||||||
|
domain_tenant_code = getattr(request.state, "domain_tenant_code", None)
|
||||||
|
domain_branch_code = getattr(request.state, "domain_branch_code", None)
|
||||||
|
|
||||||
|
tenant_code = (
|
||||||
|
session_tenant_code
|
||||||
|
or domain_tenant_code
|
||||||
|
or (request.headers.get(_TENANT_HEADER) if trust_headers else None)
|
||||||
|
or self.s.DEFAULT_TENANT_CODE
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_code = (
|
||||||
|
session_branch_code
|
||||||
|
or domain_branch_code
|
||||||
|
or (request.headers.get(_BRANCH_HEADER) if trust_headers else None)
|
||||||
|
or self.s.DEFAULT_BRANCH_CODE
|
||||||
|
)
|
||||||
|
|
||||||
|
year_code = (
|
||||||
|
session.get("active_financial_year")
|
||||||
|
or (request.headers.get(_YEAR_HEADER) if trust_headers else None)
|
||||||
|
or self.s.DEFAULT_YEAR_CODE
|
||||||
|
)
|
||||||
|
|
||||||
|
request.state.active_tenant_id = session_tenant_id
|
||||||
|
request.state.active_branch_id = session_branch_id
|
||||||
|
request.state.tenant_code = tenant_code
|
||||||
|
request.state.branch_code = branch_code
|
||||||
|
request.state.year_code = year_code
|
||||||
|
request.state.context_headers_trusted = trust_headers
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.modules.domain_management.services import normalize_request_host, resolve_domain_context
|
||||||
|
|
||||||
|
|
||||||
|
class DomainResolverMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Resolve request host to platform / tenant / consultant context.
|
||||||
|
|
||||||
|
Phase 7T.2 is intentionally read-only:
|
||||||
|
- It does not redirect users.
|
||||||
|
- It does not change database records.
|
||||||
|
- It does not override logged-in user permissions.
|
||||||
|
- It only exposes a trusted runtime context on request.state.
|
||||||
|
|
||||||
|
Later phases use this context for branding, marketplace mode, tenant subdomains,
|
||||||
|
consultant domains, and custom domain verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
super().__init__(app)
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
host_header = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||||||
|
host = normalize_request_host(host_header)
|
||||||
|
|
||||||
|
# Safe defaults; every template/route can read these without checking existence.
|
||||||
|
request.state.request_host = host
|
||||||
|
request.state.domain_resolved = False
|
||||||
|
request.state.domain_mapping_id = None
|
||||||
|
request.state.domain_name = host
|
||||||
|
request.state.domain_type = None
|
||||||
|
request.state.domain_tenant_id = None
|
||||||
|
request.state.domain_tenant_code = None
|
||||||
|
request.state.domain_branch_id = None
|
||||||
|
request.state.domain_branch_code = None
|
||||||
|
request.state.domain_consultant_id = None
|
||||||
|
request.state.domain_parent_tenant_id = None
|
||||||
|
request.state.domain_is_verified = False
|
||||||
|
request.state.domain_status = None
|
||||||
|
request.state.domain_context = {
|
||||||
|
"is_resolved": False,
|
||||||
|
"host": host,
|
||||||
|
"mapping_id": None,
|
||||||
|
"domain_name": host,
|
||||||
|
"domain_type": None,
|
||||||
|
"tenant_id": None,
|
||||||
|
"tenant_code": None,
|
||||||
|
"branch_id": None,
|
||||||
|
"branch_code": None,
|
||||||
|
"consultant_id": None,
|
||||||
|
"parent_tenant_id": None,
|
||||||
|
"is_verified": False,
|
||||||
|
"status": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files and empty/invalid host can proceed without DB lookup.
|
||||||
|
if host and not request.url.path.startswith("/static/"):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
resolved = resolve_domain_context(db, host)
|
||||||
|
if resolved.is_resolved:
|
||||||
|
request.state.domain_resolved = True
|
||||||
|
request.state.domain_mapping_id = resolved.mapping_id
|
||||||
|
request.state.domain_name = resolved.domain_name
|
||||||
|
request.state.domain_type = resolved.domain_type
|
||||||
|
request.state.domain_tenant_id = resolved.tenant_id
|
||||||
|
request.state.domain_tenant_code = resolved.tenant_code
|
||||||
|
request.state.domain_branch_id = resolved.branch_id
|
||||||
|
request.state.domain_branch_code = resolved.branch_code
|
||||||
|
request.state.domain_consultant_id = resolved.consultant_id
|
||||||
|
request.state.domain_parent_tenant_id = resolved.parent_tenant_id
|
||||||
|
request.state.domain_is_verified = resolved.is_verified
|
||||||
|
request.state.domain_status = resolved.status
|
||||||
|
request.state.domain_context = {
|
||||||
|
"is_resolved": True,
|
||||||
|
"host": resolved.host,
|
||||||
|
"mapping_id": resolved.mapping_id,
|
||||||
|
"domain_name": resolved.domain_name,
|
||||||
|
"domain_type": resolved.domain_type,
|
||||||
|
"tenant_id": resolved.tenant_id,
|
||||||
|
"tenant_code": resolved.tenant_code,
|
||||||
|
"branch_id": resolved.branch_id,
|
||||||
|
"branch_code": resolved.branch_code,
|
||||||
|
"consultant_id": resolved.consultant_id,
|
||||||
|
"parent_tenant_id": resolved.parent_tenant_id,
|
||||||
|
"is_verified": resolved.is_verified,
|
||||||
|
"status": resolved.status,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
# Domain resolution must never take the ERP down. If the domain table is
|
||||||
|
# missing during deployment or DB is temporarily unavailable, continue
|
||||||
|
# with the normal default context.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
if getattr(request.state, "domain_resolved", False):
|
||||||
|
response.headers["X-AuditFirm-Domain-Resolved"] = "1"
|
||||||
|
response.headers["X-AuditFirm-Domain-Type"] = str(getattr(request.state, "domain_type", "") or "")
|
||||||
|
return response
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
resp = await call_next(request)
|
||||||
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
resp.headers["X-Frame-Options"] = "DENY"
|
||||||
|
resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||||
|
|
||||||
|
# CSP: allow Tailwind CDN only
|
||||||
|
resp.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||||
|
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"frame-ancestors 'none';"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import secrets
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
CSRF_KEY = "csrf_token"
|
||||||
|
|
||||||
|
def get_or_create_csrf_token(request: Request) -> str:
|
||||||
|
token = request.session.get(CSRF_KEY)
|
||||||
|
if not token:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
request.session[CSRF_KEY] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
def validate_csrf(request: Request, form_token: str | None) -> None:
|
||||||
|
token = request.session.get(CSRF_KEY)
|
||||||
|
if not token or not form_token or token != form_token:
|
||||||
|
raise PermissionError("CSRF validation failed")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db.deps import get_common_db
|
||||||
|
from app.core.security.jwt_tokens import decode_token
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
def get_current_user_jwt(
|
||||||
|
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
db: Session = Depends(get_common_db),
|
||||||
|
) -> User | None:
|
||||||
|
if not creds or not creds.credentials:
|
||||||
|
return None
|
||||||
|
data = decode_token(creds.credentials)
|
||||||
|
if data.get("typ") != "access":
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = int(data.get("sub", 0) or 0)
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
def require_jwt_user(user: User | None = Depends(get_current_user_jwt)) -> User:
|
||||||
|
if not user:
|
||||||
|
raise PermissionError("Not authenticated (JWT)")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWTError
|
||||||
|
|
||||||
|
from app.core.settings import get_settings
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def encode_access_token(payload: dict[str, Any], expires_minutes: int) -> str:
|
||||||
|
s = get_settings()
|
||||||
|
now = utcnow()
|
||||||
|
exp = now + timedelta(minutes=expires_minutes)
|
||||||
|
token_payload = {
|
||||||
|
**payload,
|
||||||
|
"iss": s.JWT_ISSUER,
|
||||||
|
"aud": s.JWT_AUDIENCE,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int(exp.timestamp()),
|
||||||
|
"typ": "access",
|
||||||
|
}
|
||||||
|
return jwt.encode(token_payload, s.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
data = jwt.decode(
|
||||||
|
token,
|
||||||
|
s.SECRET_KEY,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
audience=s.JWT_AUDIENCE,
|
||||||
|
issuer=s.JWT_ISSUER,
|
||||||
|
options={"require": ["exp", "iat", "iss", "aud"]},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
except PyJWTError as e:
|
||||||
|
raise PermissionError("Invalid token") from e
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import secrets
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
OTP_CODE_KEY = "otp_code"
|
||||||
|
OTP_VERIFIED_KEY = "otp_verified"
|
||||||
|
|
||||||
|
def start_otp(request: Request) -> str:
|
||||||
|
# 6-digit numeric code
|
||||||
|
code = str(secrets.randbelow(900000) + 100000)
|
||||||
|
request.session[OTP_CODE_KEY] = code
|
||||||
|
request.session[OTP_VERIFIED_KEY] = False
|
||||||
|
return code
|
||||||
|
|
||||||
|
def verify_otp(request: Request, code: str) -> bool:
|
||||||
|
expected = request.session.get(OTP_CODE_KEY)
|
||||||
|
if expected and code and code.strip() == expected:
|
||||||
|
request.session[OTP_VERIFIED_KEY] = True
|
||||||
|
request.session.pop(OTP_CODE_KEY, None)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_otp_verified(request: Request) -> bool:
|
||||||
|
return bool(request.session.get(OTP_VERIFIED_KEY))
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from passlib.context import CryptContext
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def hash_password(p: str) -> str:
|
||||||
|
return _pwd.hash(p)
|
||||||
|
|
||||||
|
def verify_password(p: str, h: str) -> bool:
|
||||||
|
return _pwd.verify(p, h)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import Request, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db.deps import get_common_db
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
from app.modules.core.tenancy.models import Branch
|
||||||
|
from app.modules.core.tenancy.settings_models import BranchSettings
|
||||||
|
|
||||||
|
SESSION_USER_ID_KEY = "user_id"
|
||||||
|
SESSION_LOGIN_AT_KEY = "login_at"
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def get_current_user(request: Request, db: Session = Depends(get_common_db)) -> User | None:
|
||||||
|
user_id = request.session.get(SESSION_USER_ID_KEY)
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none()
|
||||||
|
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Enforce session duration from BranchSettings
|
||||||
|
login_at = request.session.get(SESSION_LOGIN_AT_KEY)
|
||||||
|
if login_at:
|
||||||
|
try:
|
||||||
|
login_at_dt = datetime.fromisoformat(login_at)
|
||||||
|
except Exception:
|
||||||
|
login_at_dt = None
|
||||||
|
else:
|
||||||
|
login_at_dt = None
|
||||||
|
|
||||||
|
bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == user.branch_id)).scalar_one_or_none()
|
||||||
|
max_minutes = bs.session_duration_minutes if bs else 480
|
||||||
|
|
||||||
|
if login_at_dt:
|
||||||
|
if _now_utc() - login_at_dt > timedelta(minutes=max_minutes):
|
||||||
|
# expire session
|
||||||
|
request.session.pop(SESSION_USER_ID_KEY, None)
|
||||||
|
request.session.pop(SESSION_LOGIN_AT_KEY, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def require_login(user: User | None = Depends(get_current_user)) -> User:
|
||||||
|
if not user:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
APP_NAME: str = "Audit_Firm_v2.0.3.6"
|
||||||
|
ENV: str = "dev"
|
||||||
|
DEBUG: bool = True
|
||||||
|
SECRET_KEY: str = "change-me-to-a-long-random-string"
|
||||||
|
|
||||||
|
COOKIE_SECURE: bool = False
|
||||||
|
COOKIE_SAMESITE: str = "lax"
|
||||||
|
COOKIE_SESSION_NAME: str = "af2sid"
|
||||||
|
|
||||||
|
DB_BACKEND: str = Field(default="sqlite", description="sqlite|postgres")
|
||||||
|
|
||||||
|
SQLITE_COMMON_PATH: str = "./data/common.db"
|
||||||
|
|
||||||
|
PG_HOST: str = "127.0.0.1"
|
||||||
|
PG_PORT: int = 5432
|
||||||
|
PG_USER: str = "postgres"
|
||||||
|
PG_PASSWORD: str = "postgres"
|
||||||
|
PG_DB_COMMON: str = "audit_common"
|
||||||
|
|
||||||
|
DEFAULT_TENANT_CODE: str = "default"
|
||||||
|
DEFAULT_BRANCH_CODE: str = "main"
|
||||||
|
DEFAULT_YEAR_CODE: str = "2025-26"
|
||||||
|
DEFAULT_TIMEZONE: str = "Asia/Kolkata"
|
||||||
|
|
||||||
|
# Public base URL used for email links such as invite and password reset.
|
||||||
|
# In Coolify production set this to https://your-erp-domain.
|
||||||
|
ERP_PUBLIC_BASE_URL: str = "http://localhost:8000"
|
||||||
|
|
||||||
|
# Print OTP to server logs only in local/dev troubleshooting. Keep false in UAT/production.
|
||||||
|
DEV_AUTH_OTP_PRINT: bool = False
|
||||||
|
|
||||||
|
# Context headers are disabled by default for public deployments.
|
||||||
|
# When disabled, browser/client supplied X-Tenant-Code, X-Branch-Code,
|
||||||
|
# and X-Year-Code are ignored. Enable only for trusted internal runners
|
||||||
|
# or reverse proxies that also restrict/strip external request headers.
|
||||||
|
TRUST_CONTEXT_HEADERS: bool = False
|
||||||
|
TRUST_CONTEXT_HEADER_HOSTS: str = "127.0.0.1,localhost,::1"
|
||||||
|
CONTEXT_HEADER_SECRET: str = ""
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_ISSUER: str = "Audit_Firm_v2.0.3.6"
|
||||||
|
JWT_AUDIENCE: str = "audit_firm_clients"
|
||||||
|
JWT_ACCESS_MINUTES: int = 15
|
||||||
|
JWT_REFRESH_DAYS: int = 30
|
||||||
|
|
||||||
|
# Bootstrap Admin
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL: str = "admin@auditfirm.local"
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD: str = "ChangeMe@123"
|
||||||
|
|
||||||
|
INVITE_TOKEN_HOURS: int = 72
|
||||||
|
PASSWORD_RESET_HOURS: int = 2
|
||||||
|
PASSWORD_MIN_LENGTH: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
s = Settings()
|
||||||
|
|
||||||
|
# Normalize cookie values
|
||||||
|
s.COOKIE_SAMESITE = (s.COOKIE_SAMESITE or "lax").lower()
|
||||||
|
if s.COOKIE_SAMESITE not in {"lax", "strict", "none"}:
|
||||||
|
s.COOKIE_SAMESITE = "lax"
|
||||||
|
|
||||||
|
return s
|
||||||
@@ -0,0 +1,951 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, select, text
|
||||||
|
|
||||||
|
from app.core.db.common import CommonBase, CommonEngine, CommonSessionLocal
|
||||||
|
from app.core.security.passwords import hash_password
|
||||||
|
from app.core.settings import get_settings
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken
|
||||||
|
from app.modules.core.audit.models import AuditLog
|
||||||
|
from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole
|
||||||
|
from app.modules.core.rbac.permissions_registry import PERMISSIONS
|
||||||
|
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
|
||||||
|
from app.modules.core.tenancy.settings_models import BranchSettings
|
||||||
|
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip
|
||||||
|
from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest
|
||||||
|
from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate
|
||||||
|
from app.modules.billing.models import BillingSettings, BillingInvoice, BillingInvoiceLine, BillingFeeGroup, BillingFeeGroupService
|
||||||
|
from app.modules.platform_billing.models import PlatformBillingAccount, PlatformInvoice, PlatformInvoiceLine, PlatformPayment, PlatformPlan, PlatformPlanFeature, PlatformSubscription
|
||||||
|
from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment
|
||||||
|
from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion, DocumentAccessLog
|
||||||
|
from app.modules.alerts.models import UserAlert
|
||||||
|
from app.modules.notice_cases.models import NoticeCase, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder, NoticeCaseDocument
|
||||||
|
from app.modules.notifications.automation import start_notification_scheduler
|
||||||
|
|
||||||
|
DEFAULT_ROLES = [
|
||||||
|
"System Admin",
|
||||||
|
"Firm Admin",
|
||||||
|
"Partner",
|
||||||
|
"Branch Manager",
|
||||||
|
"Staff",
|
||||||
|
"Client",
|
||||||
|
"Consultant",
|
||||||
|
]
|
||||||
|
|
||||||
|
LEGACY_ROLE_RENAMES = {
|
||||||
|
"SystemAdmin": "System Admin",
|
||||||
|
"Manager": "Branch Manager",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_PERMISSIONS = list(PERMISSIONS.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user_lifecycle_columns() -> None:
|
||||||
|
inspector = inspect(CommonEngine)
|
||||||
|
existing = {c["name"] for c in inspector.get_columns("users")} if "users" in inspector.get_table_names() else set()
|
||||||
|
dialect = CommonEngine.dialect.name
|
||||||
|
ddl_map = {
|
||||||
|
"allow_login": "BOOLEAN DEFAULT TRUE",
|
||||||
|
"is_locked": "BOOLEAN DEFAULT FALSE",
|
||||||
|
"locked_at_utc": "TIMESTAMP NULL",
|
||||||
|
"deleted_at": "TIMESTAMP NULL",
|
||||||
|
"must_change_password": "BOOLEAN DEFAULT FALSE",
|
||||||
|
"password_changed_at_utc": "TIMESTAMP NULL",
|
||||||
|
}
|
||||||
|
for col, ddl in ddl_map.items():
|
||||||
|
if col in existing:
|
||||||
|
continue
|
||||||
|
with CommonEngine.begin() as conn:
|
||||||
|
conn.execute(text(f"ALTER TABLE users ADD COLUMN {col} {ddl}"))
|
||||||
|
if dialect == "postgres" and col in {"allow_login", "is_locked"}:
|
||||||
|
default_value = "TRUE" if col == "allow_login" else "FALSE"
|
||||||
|
conn.execute(text(f"UPDATE users SET {col} = {default_value} WHERE {col} IS NULL"))
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_PERMISSION_MAP = {
|
||||||
|
"System Admin": [
|
||||||
|
"system.settings.view",
|
||||||
|
"system.settings.edit",
|
||||||
|
"system.settings.manage",
|
||||||
|
"users.view",
|
||||||
|
"users.manage",
|
||||||
|
"users.invite",
|
||||||
|
"users.reset_password",
|
||||||
|
"rbac.view",
|
||||||
|
"rbac.manage",
|
||||||
|
"audit.view",
|
||||||
|
"alerts.view_self",
|
||||||
|
"alerts.manage",
|
||||||
|
"services.view",
|
||||||
|
"services.create",
|
||||||
|
"services.edit",
|
||||||
|
"services.selection.manage",
|
||||||
|
"services.deactivate",
|
||||||
|
"services.cross_branch",
|
||||||
|
"services.cross_tenant",
|
||||||
|
"services.catalogue.manage",
|
||||||
|
"service_tasks.view",
|
||||||
|
"service_tasks.create",
|
||||||
|
"service_tasks.edit",
|
||||||
|
"service_tasks.deactivate",
|
||||||
|
"clients.view",
|
||||||
|
"clients.create",
|
||||||
|
"clients.import",
|
||||||
|
"clients.edit",
|
||||||
|
"clients.deactivate",
|
||||||
|
"clients.activate",
|
||||||
|
"clients.archive",
|
||||||
|
"clients.restore",
|
||||||
|
"clients.assign_partner",
|
||||||
|
"clients.cross_branch",
|
||||||
|
"clients.cross_tenant",
|
||||||
|
"clients.export",
|
||||||
|
"clients.audit_log.view",
|
||||||
|
"employees.dashboard.view",
|
||||||
|
"employees.view",
|
||||||
|
"employees.create",
|
||||||
|
"employees.edit",
|
||||||
|
"employees.status",
|
||||||
|
"employees.cross_branch",
|
||||||
|
"employees.cross_tenant",
|
||||||
|
"consultants.view",
|
||||||
|
"consultants.manage",
|
||||||
|
"consultants.link_clients",
|
||||||
|
"consultants.cross_branch",
|
||||||
|
"consultants.managed_clients.manage",
|
||||||
|
"consultants.workspace.manage",
|
||||||
|
"consultants.service_requests.manage",
|
||||||
|
"consultants.conversions.manage",
|
||||||
|
|
||||||
|
# System Admin has billing support/view access only.
|
||||||
|
# System Admin must not create, import, generate, approve, post, cancel,
|
||||||
|
# or record firm-level client bills.
|
||||||
|
"billing.view",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.cross_branch",
|
||||||
|
"billing.cross_tenant",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
|
||||||
|
# Platform/SaaS billing is System Admin revenue layer.
|
||||||
|
"platform_billing.view",
|
||||||
|
"platform_billing.create",
|
||||||
|
"platform_billing.edit",
|
||||||
|
"platform_billing.generate",
|
||||||
|
"platform_billing.post",
|
||||||
|
"platform_billing.cancel",
|
||||||
|
"platform_billing.payment.create",
|
||||||
|
"platform_billing.payment.view",
|
||||||
|
"platform_billing.reports",
|
||||||
|
"platform_plans.manage",
|
||||||
|
"platform_subscriptions.manage",
|
||||||
|
|
||||||
|
# Marketplace / public lead management.
|
||||||
|
"marketplace_leads.view",
|
||||||
|
"marketplace_leads.create",
|
||||||
|
"marketplace_leads.assign",
|
||||||
|
"marketplace_leads.update",
|
||||||
|
"marketplace_leads.convert",
|
||||||
|
"marketplace_leads.reports",
|
||||||
|
"marketplace_leads.view_assigned",
|
||||||
|
|
||||||
|
"alerts.view_self",
|
||||||
|
"employees.ess.view", "employees.ess.profile.edit",
|
||||||
|
"employees.work.view_self",
|
||||||
|
"employees.work.manage",
|
||||||
|
"employees.progress.view",
|
||||||
|
"employees.registration.request",
|
||||||
|
"employees.registration.approve",
|
||||||
|
"employees.attendance.punch",
|
||||||
|
"employees.attendance.view_self",
|
||||||
|
"employees.attendance.view_all",
|
||||||
|
"employees.attendance.approve",
|
||||||
|
"employees.leave.apply",
|
||||||
|
"employees.leave.view_self",
|
||||||
|
"employees.leave.view_all",
|
||||||
|
"employees.leave.approve",
|
||||||
|
"employees.leave_type.manage",
|
||||||
|
"employees.leave_balance.manage",
|
||||||
|
"employees.documents.view_self",
|
||||||
|
"employees.documents.upload_self",
|
||||||
|
"employees.documents.view_all",
|
||||||
|
"employees.documents.manage",
|
||||||
|
"employees.documents.verify",
|
||||||
|
"employees.documents.delete",
|
||||||
|
"employees.document_type.manage",
|
||||||
|
"employees.onboarding.view",
|
||||||
|
"employees.onboarding.manage",
|
||||||
|
"employees.onboarding.approve",
|
||||||
|
"employees.offboarding.view",
|
||||||
|
"employees.offboarding.manage",
|
||||||
|
"employees.offboarding.approve",
|
||||||
|
"employees.offboarding.request_self",
|
||||||
|
"employees.payroll.payout",
|
||||||
|
"employees.payroll.view_self",
|
||||||
|
"employees.payroll.view",
|
||||||
|
"employees.payroll.run",
|
||||||
|
"employees.payroll.structure.manage",
|
||||||
|
"employees.import",
|
||||||
|
"employees.import.employee",
|
||||||
|
"employees.import.leave_type",
|
||||||
|
"employees.import.leave_balance",
|
||||||
|
"employees.import.salary_structure",
|
||||||
|
],
|
||||||
|
"Firm Admin": [
|
||||||
|
"system.settings.view",
|
||||||
|
"system.settings.edit",
|
||||||
|
"users.view",
|
||||||
|
"users.manage",
|
||||||
|
"users.invite",
|
||||||
|
"users.reset_password",
|
||||||
|
"audit.view",
|
||||||
|
"alerts.view_self",
|
||||||
|
"alerts.manage",
|
||||||
|
"services.view",
|
||||||
|
"services.create",
|
||||||
|
"services.edit",
|
||||||
|
"services.selection.manage",
|
||||||
|
"services.deactivate",
|
||||||
|
"services.cross_branch",
|
||||||
|
"service_tasks.view",
|
||||||
|
"service_tasks.create",
|
||||||
|
"service_tasks.edit",
|
||||||
|
"service_tasks.deactivate",
|
||||||
|
"clients.view",
|
||||||
|
"clients.create",
|
||||||
|
"clients.import",
|
||||||
|
"clients.edit",
|
||||||
|
"clients.deactivate",
|
||||||
|
"clients.activate",
|
||||||
|
"clients.archive",
|
||||||
|
"clients.restore",
|
||||||
|
"clients.assign_partner",
|
||||||
|
"clients.cross_branch",
|
||||||
|
"clients.export",
|
||||||
|
"clients.audit_log.view",
|
||||||
|
"documents.view",
|
||||||
|
"documents.upload",
|
||||||
|
"documents.download",
|
||||||
|
"documents.delete",
|
||||||
|
"documents.audit.view",
|
||||||
|
"employees.dashboard.view",
|
||||||
|
"employees.view",
|
||||||
|
"employees.create",
|
||||||
|
"employees.edit",
|
||||||
|
"employees.status",
|
||||||
|
"employees.cross_branch",
|
||||||
|
"consultants.view",
|
||||||
|
"consultants.manage",
|
||||||
|
"consultants.link_clients",
|
||||||
|
"consultants.cross_branch",
|
||||||
|
"consultants.managed_clients.manage",
|
||||||
|
"consultants.workspace.manage",
|
||||||
|
"consultants.service_requests.manage",
|
||||||
|
"consultants.conversions.manage",
|
||||||
|
|
||||||
|
"billing.view",
|
||||||
|
"billing.create",
|
||||||
|
"billing.edit",
|
||||||
|
"billing.approve",
|
||||||
|
"billing.post",
|
||||||
|
"billing.cancel",
|
||||||
|
"billing.payment.create",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.cross_branch",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
"billing_fee_structure.import",
|
||||||
|
"billing_fee_structure.edit",
|
||||||
|
"billing_fee_structure.delete",
|
||||||
|
"billing_invoice.generate",
|
||||||
|
"billing_invoice.bulk_generate",
|
||||||
|
|
||||||
|
# Audit Firm can work on leads assigned to its audit firm.
|
||||||
|
"marketplace_leads.view_assigned",
|
||||||
|
"marketplace_leads.update",
|
||||||
|
"marketplace_leads.convert",
|
||||||
|
|
||||||
|
"employees.ess.view", "employees.ess.profile.edit",
|
||||||
|
"employees.work.view_self",
|
||||||
|
"employees.work.manage",
|
||||||
|
"employees.progress.view",
|
||||||
|
"employees.registration.request",
|
||||||
|
"employees.registration.approve",
|
||||||
|
"employees.attendance.punch",
|
||||||
|
"employees.attendance.view_self",
|
||||||
|
"employees.attendance.view_all",
|
||||||
|
"employees.attendance.approve",
|
||||||
|
"employees.leave.apply",
|
||||||
|
"employees.leave.view_self",
|
||||||
|
"employees.leave.view_all",
|
||||||
|
"employees.leave.approve",
|
||||||
|
"employees.leave_type.manage",
|
||||||
|
"employees.leave_balance.manage",
|
||||||
|
"employees.documents.view_self",
|
||||||
|
"employees.documents.upload_self",
|
||||||
|
"employees.documents.view_all",
|
||||||
|
"employees.documents.manage",
|
||||||
|
"employees.documents.verify",
|
||||||
|
"employees.documents.delete",
|
||||||
|
"employees.document_type.manage",
|
||||||
|
"employees.onboarding.view",
|
||||||
|
"employees.onboarding.manage",
|
||||||
|
"employees.onboarding.approve",
|
||||||
|
"employees.offboarding.view",
|
||||||
|
"employees.offboarding.manage",
|
||||||
|
"employees.offboarding.approve",
|
||||||
|
"employees.offboarding.request_self",
|
||||||
|
"employees.payroll.payout",
|
||||||
|
"employees.payroll.view_self",
|
||||||
|
"employees.payroll.view",
|
||||||
|
"employees.payroll.run",
|
||||||
|
"employees.payroll.structure.manage",
|
||||||
|
"employees.import",
|
||||||
|
"employees.import.employee",
|
||||||
|
"employees.import.leave_type",
|
||||||
|
"employees.import.leave_balance",
|
||||||
|
"employees.import.salary_structure",
|
||||||
|
],
|
||||||
|
"Partner": [
|
||||||
|
"users.view",
|
||||||
|
"system.settings.view",
|
||||||
|
"services.view",
|
||||||
|
"services.cross_branch",
|
||||||
|
"service_tasks.view",
|
||||||
|
"clients.view",
|
||||||
|
"clients.create",
|
||||||
|
"clients.import",
|
||||||
|
"clients.edit",
|
||||||
|
"clients.deactivate",
|
||||||
|
"clients.activate",
|
||||||
|
"clients.archive",
|
||||||
|
"clients.restore",
|
||||||
|
"clients.export",
|
||||||
|
"clients.audit_log.view",
|
||||||
|
"documents.view",
|
||||||
|
"documents.upload",
|
||||||
|
"documents.download",
|
||||||
|
"documents.delete",
|
||||||
|
"clients.view.own_only",
|
||||||
|
"employees.dashboard.view",
|
||||||
|
"employees.view",
|
||||||
|
"employees.create",
|
||||||
|
"employees.edit",
|
||||||
|
"employees.status",
|
||||||
|
"consultants.view",
|
||||||
|
"consultants.link_clients",
|
||||||
|
"consultants.cross_branch",
|
||||||
|
"consultants.managed_clients.manage",
|
||||||
|
"consultants.workspace.manage",
|
||||||
|
|
||||||
|
# Partner has almost the same firm-billing privileges as Firm Admin,
|
||||||
|
# but is intentionally scoped to own clients through billing.view_own.
|
||||||
|
"billing.view",
|
||||||
|
"billing.create",
|
||||||
|
"billing.edit",
|
||||||
|
"billing.approve",
|
||||||
|
"billing.post",
|
||||||
|
"billing.cancel",
|
||||||
|
"billing.payment.create",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.view_own",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
"billing_fee_structure.import",
|
||||||
|
"billing_fee_structure.edit",
|
||||||
|
"billing_fee_structure.delete",
|
||||||
|
"billing_invoice.generate",
|
||||||
|
"billing_invoice.bulk_generate",
|
||||||
|
|
||||||
|
# Partner can handle assigned marketplace leads for own clients/work.
|
||||||
|
"marketplace_leads.view_assigned",
|
||||||
|
"marketplace_leads.update",
|
||||||
|
"marketplace_leads.convert",
|
||||||
|
|
||||||
|
"employees.ess.view", "employees.ess.profile.edit",
|
||||||
|
"employees.work.view_self",
|
||||||
|
"employees.work.manage",
|
||||||
|
"employees.progress.view",
|
||||||
|
"employees.registration.request",
|
||||||
|
"employees.registration.approve",
|
||||||
|
"employees.attendance.punch",
|
||||||
|
"employees.attendance.view_self",
|
||||||
|
"employees.attendance.view_all",
|
||||||
|
"employees.attendance.approve",
|
||||||
|
"employees.leave.apply",
|
||||||
|
"employees.leave.view_self",
|
||||||
|
"employees.leave.view_all",
|
||||||
|
"employees.leave.approve",
|
||||||
|
"employees.leave_type.manage",
|
||||||
|
"employees.leave_balance.manage",
|
||||||
|
"employees.documents.view_self",
|
||||||
|
"employees.documents.upload_self",
|
||||||
|
"employees.documents.view_all",
|
||||||
|
"employees.documents.manage",
|
||||||
|
"employees.documents.verify",
|
||||||
|
"employees.documents.delete",
|
||||||
|
"employees.document_type.manage",
|
||||||
|
"employees.onboarding.view",
|
||||||
|
"employees.onboarding.manage",
|
||||||
|
"employees.onboarding.approve",
|
||||||
|
"employees.offboarding.view",
|
||||||
|
"employees.offboarding.manage",
|
||||||
|
"employees.offboarding.approve",
|
||||||
|
"employees.offboarding.request_self",
|
||||||
|
"employees.payroll.payout",
|
||||||
|
"employees.payroll.view_self",
|
||||||
|
"employees.payroll.view",
|
||||||
|
"employees.payroll.run",
|
||||||
|
"employees.payroll.structure.manage",
|
||||||
|
"employees.import",
|
||||||
|
"employees.import.employee",
|
||||||
|
"employees.import.leave_type",
|
||||||
|
"employees.import.leave_balance",
|
||||||
|
"employees.import.salary_structure",
|
||||||
|
],
|
||||||
|
"Branch Manager": [
|
||||||
|
"users.view",
|
||||||
|
"services.view",
|
||||||
|
"services.create",
|
||||||
|
"services.edit",
|
||||||
|
"service_tasks.view",
|
||||||
|
"service_tasks.create",
|
||||||
|
"service_tasks.edit",
|
||||||
|
"clients.view",
|
||||||
|
"clients.create",
|
||||||
|
"clients.edit",
|
||||||
|
"clients.deactivate",
|
||||||
|
"clients.activate",
|
||||||
|
"clients.export",
|
||||||
|
"clients.audit_log.view",
|
||||||
|
"documents.view",
|
||||||
|
"documents.upload",
|
||||||
|
"documents.download",
|
||||||
|
"employees.dashboard.view",
|
||||||
|
"employees.view",
|
||||||
|
"employees.create",
|
||||||
|
"employees.edit",
|
||||||
|
"employees.status",
|
||||||
|
"consultants.view",
|
||||||
|
|
||||||
|
"billing.view",
|
||||||
|
"billing.create",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
"employees.ess.view", "employees.ess.profile.edit",
|
||||||
|
"employees.work.view_self",
|
||||||
|
"employees.work.manage",
|
||||||
|
"employees.progress.view",
|
||||||
|
"employees.registration.request",
|
||||||
|
"employees.registration.approve",
|
||||||
|
"employees.attendance.punch",
|
||||||
|
"employees.attendance.view_self",
|
||||||
|
"employees.attendance.view_all",
|
||||||
|
"employees.attendance.approve",
|
||||||
|
"employees.leave.apply",
|
||||||
|
"employees.leave.view_self",
|
||||||
|
"employees.leave.view_all",
|
||||||
|
"employees.leave.approve",
|
||||||
|
"employees.leave_type.manage",
|
||||||
|
"employees.leave_balance.manage",
|
||||||
|
"employees.documents.view_self",
|
||||||
|
"employees.documents.upload_self",
|
||||||
|
"employees.documents.view_all",
|
||||||
|
"employees.documents.manage",
|
||||||
|
"employees.documents.verify",
|
||||||
|
"employees.documents.delete",
|
||||||
|
"employees.document_type.manage",
|
||||||
|
"employees.onboarding.view",
|
||||||
|
"employees.onboarding.manage",
|
||||||
|
"employees.onboarding.approve",
|
||||||
|
"employees.offboarding.view",
|
||||||
|
"employees.offboarding.manage",
|
||||||
|
"employees.offboarding.approve",
|
||||||
|
"employees.offboarding.request_self",
|
||||||
|
"employees.payroll.view_self",
|
||||||
|
"employees.payroll.view",
|
||||||
|
"employees.payroll.run",
|
||||||
|
"employees.payroll.structure.manage",
|
||||||
|
"employees.import",
|
||||||
|
"employees.import.employee",
|
||||||
|
"employees.import.leave_type",
|
||||||
|
"employees.import.leave_balance",
|
||||||
|
"employees.import.salary_structure",
|
||||||
|
],
|
||||||
|
"Staff": [
|
||||||
|
"alerts.view_self",
|
||||||
|
"employees.ess.view",
|
||||||
|
"employees.ess.profile.edit",
|
||||||
|
"employees.work.view_self",
|
||||||
|
"employees.registration.request",
|
||||||
|
"employees.attendance.punch",
|
||||||
|
"employees.attendance.view_self",
|
||||||
|
"employees.leave.apply",
|
||||||
|
"employees.leave.view_self",
|
||||||
|
"employees.documents.view_self",
|
||||||
|
"employees.documents.upload_self",
|
||||||
|
"employees.offboarding.request_self",
|
||||||
|
"employees.payroll.view_self",
|
||||||
|
"documents.view",
|
||||||
|
"documents.upload",
|
||||||
|
"documents.download",
|
||||||
|
],
|
||||||
|
"Client": [],
|
||||||
|
"Consultant": [
|
||||||
|
"alerts.view_self",
|
||||||
|
"consultants.portal.view",
|
||||||
|
"consultants.managed_clients.manage",
|
||||||
|
"consultants.workspace.manage",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Keep existing databases aligned with the billing permission policy.
|
||||||
|
# The normal startup seed only adds missing permissions; it does not remove
|
||||||
|
# permissions that were granted in an earlier patch. This sync is limited to
|
||||||
|
# billing permissions for these default roles so existing non-billing features
|
||||||
|
# and custom modules are not touched.
|
||||||
|
BILLING_PERMISSION_CODES = {
|
||||||
|
"billing.view",
|
||||||
|
"billing.create",
|
||||||
|
"billing.edit",
|
||||||
|
"billing.approve",
|
||||||
|
"billing.post",
|
||||||
|
"billing.cancel",
|
||||||
|
"billing.payment.create",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.cross_branch",
|
||||||
|
"billing.cross_tenant",
|
||||||
|
"billing.view_own",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
"billing_fee_structure.import",
|
||||||
|
"billing_fee_structure.edit",
|
||||||
|
"billing_fee_structure.delete",
|
||||||
|
"billing_invoice.generate",
|
||||||
|
"billing_invoice.bulk_generate",
|
||||||
|
}
|
||||||
|
|
||||||
|
BILLING_ROLE_PERMISSION_SYNC = {
|
||||||
|
"System Admin": {
|
||||||
|
"billing.view",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.cross_branch",
|
||||||
|
"billing.cross_tenant",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
},
|
||||||
|
"Firm Admin": {
|
||||||
|
"billing.view",
|
||||||
|
"billing.create",
|
||||||
|
"billing.edit",
|
||||||
|
"billing.approve",
|
||||||
|
"billing.post",
|
||||||
|
"billing.cancel",
|
||||||
|
"billing.payment.create",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.cross_branch",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
"billing_fee_structure.import",
|
||||||
|
"billing_fee_structure.edit",
|
||||||
|
"billing_fee_structure.delete",
|
||||||
|
"billing_invoice.generate",
|
||||||
|
"billing_invoice.bulk_generate",
|
||||||
|
},
|
||||||
|
"Partner": {
|
||||||
|
"billing.view",
|
||||||
|
"billing.create",
|
||||||
|
"billing.edit",
|
||||||
|
"billing.approve",
|
||||||
|
"billing.post",
|
||||||
|
"billing.cancel",
|
||||||
|
"billing.payment.create",
|
||||||
|
"billing.payment.view",
|
||||||
|
"billing.reports",
|
||||||
|
"billing.view_own",
|
||||||
|
"billing_fee_structure.view",
|
||||||
|
"billing_fee_structure.import",
|
||||||
|
"billing_fee_structure.edit",
|
||||||
|
"billing_fee_structure.delete",
|
||||||
|
"billing_invoice.generate",
|
||||||
|
"billing_invoice.bulk_generate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NOTICE_CASE_ROLE_PERMISSIONS = {
|
||||||
|
"System Admin": [
|
||||||
|
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||||
|
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||||
|
"notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete",
|
||||||
|
"notice_cases.cross_branch", "notice_cases.cross_tenant",
|
||||||
|
],
|
||||||
|
"Firm Admin": [
|
||||||
|
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||||
|
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||||
|
"notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete",
|
||||||
|
"notice_cases.cross_branch",
|
||||||
|
],
|
||||||
|
"Partner": [
|
||||||
|
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||||
|
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||||
|
"notice_cases.documents.upload", "notice_cases.documents.download",
|
||||||
|
],
|
||||||
|
"Branch Manager": [
|
||||||
|
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||||
|
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||||
|
"notice_cases.documents.upload", "notice_cases.documents.download",
|
||||||
|
"notice_cases.cross_branch",
|
||||||
|
],
|
||||||
|
"Staff": [
|
||||||
|
"notice_cases.view", "notice_cases.events.manage",
|
||||||
|
"notice_cases.documents.upload", "notice_cases.documents.download",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for _role_name, _codes in NOTICE_CASE_ROLE_PERMISSIONS.items():
|
||||||
|
_target = ROLE_PERMISSION_MAP.setdefault(_role_name, [])
|
||||||
|
for _code in _codes:
|
||||||
|
if isinstance(_target, set):
|
||||||
|
_target.add(_code)
|
||||||
|
elif _code not in _target:
|
||||||
|
_target.append(_code)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _fy_dates_from_code(year_code: str) -> tuple[date, date, str]:
|
||||||
|
parts = (year_code or "").split("-", 1)
|
||||||
|
try:
|
||||||
|
start_year = int(parts[0])
|
||||||
|
except Exception:
|
||||||
|
start_year = 2025
|
||||||
|
end_year = start_year + 1
|
||||||
|
assessment_year = f"{end_year}-{str(end_year + 1)[-2:]}"
|
||||||
|
return date(start_year, 4, 1), date(end_year, 3, 31), assessment_year
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_financial_year(db, tenant_id: int, year_code: str) -> FinancialYear:
|
||||||
|
fy = db.execute(
|
||||||
|
select(FinancialYear).where(
|
||||||
|
FinancialYear.tenant_id == tenant_id,
|
||||||
|
FinancialYear.year_code == year_code,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if fy:
|
||||||
|
return fy
|
||||||
|
|
||||||
|
start_date, end_date, assessment_year = _fy_dates_from_code(year_code)
|
||||||
|
current_exists = db.execute(
|
||||||
|
select(FinancialYear.id).where(
|
||||||
|
FinancialYear.tenant_id == tenant_id,
|
||||||
|
FinancialYear.is_current.is_(True),
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
fy = FinancialYear(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
year_code=year_code,
|
||||||
|
assessment_year=assessment_year,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
is_current=current_exists is None,
|
||||||
|
is_locked=False,
|
||||||
|
created_at_utc=now,
|
||||||
|
updated_at_utc=now,
|
||||||
|
)
|
||||||
|
db.add(fy)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fy)
|
||||||
|
return fy
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_financial_years_for_all_tenants(db, default_year_code: str) -> None:
|
||||||
|
tenant_ids = db.execute(select(Tenant.id)).scalars().all()
|
||||||
|
for tenant_id in tenant_ids:
|
||||||
|
_ensure_financial_year(db, int(tenant_id), default_year_code)
|
||||||
|
|
||||||
|
def on_startup(app: FastAPI) -> None:
|
||||||
|
s = get_settings()
|
||||||
|
inspector = inspect(CommonEngine)
|
||||||
|
existing_tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
if "audit_logs" not in existing_tables:
|
||||||
|
CommonBase.metadata.create_all(bind=CommonEngine, tables=[AuditLog.__table__])
|
||||||
|
existing_tables = set(inspect(CommonEngine).get_table_names())
|
||||||
|
|
||||||
|
required_tables = {
|
||||||
|
"tenants",
|
||||||
|
"branches",
|
||||||
|
"branch_settings",
|
||||||
|
"users",
|
||||||
|
"roles",
|
||||||
|
"permissions",
|
||||||
|
"role_permissions",
|
||||||
|
"user_roles",
|
||||||
|
"audit_logs",
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_optional_tables = []
|
||||||
|
if "invite_tokens" not in existing_tables:
|
||||||
|
missing_optional_tables.append(InviteToken.__table__)
|
||||||
|
if "password_reset_tokens" not in existing_tables:
|
||||||
|
missing_optional_tables.append(PasswordResetToken.__table__)
|
||||||
|
if "service_categories" not in existing_tables:
|
||||||
|
missing_optional_tables.append(ServiceCategory.__table__)
|
||||||
|
if "service_catalogues" not in existing_tables:
|
||||||
|
missing_optional_tables.append(ServiceCatalogue.__table__)
|
||||||
|
if "firm_service_selections" not in existing_tables:
|
||||||
|
missing_optional_tables.append(FirmServiceSelection.__table__)
|
||||||
|
if "firm_service_task_templates" not in existing_tables:
|
||||||
|
missing_optional_tables.append(FirmServiceTaskTemplate.__table__)
|
||||||
|
billing_tables = [
|
||||||
|
("billing_settings", BillingSettings.__table__),
|
||||||
|
("billing_fee_groups", BillingFeeGroup.__table__),
|
||||||
|
("billing_fee_group_services", BillingFeeGroupService.__table__),
|
||||||
|
("billing_invoices", BillingInvoice.__table__),
|
||||||
|
("billing_invoice_lines", BillingInvoiceLine.__table__),
|
||||||
|
]
|
||||||
|
for table_name, table in billing_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
missing_optional_tables.append(table)
|
||||||
|
|
||||||
|
platform_billing_tables = [
|
||||||
|
("platform_plans", PlatformPlan.__table__),
|
||||||
|
("platform_plan_features", PlatformPlanFeature.__table__),
|
||||||
|
("platform_billing_accounts", PlatformBillingAccount.__table__),
|
||||||
|
("platform_subscriptions", PlatformSubscription.__table__),
|
||||||
|
("platform_invoices", PlatformInvoice.__table__),
|
||||||
|
("platform_invoice_lines", PlatformInvoiceLine.__table__),
|
||||||
|
("platform_payments", PlatformPayment.__table__),
|
||||||
|
]
|
||||||
|
marketplace_tables = [
|
||||||
|
("marketplace_leads", MarketplaceLead.__table__),
|
||||||
|
("marketplace_lead_assignments", MarketplaceLeadAssignment.__table__),
|
||||||
|
]
|
||||||
|
for table_name, table in platform_billing_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
missing_optional_tables.append(table)
|
||||||
|
for table_name, table in marketplace_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
missing_optional_tables.append(table)
|
||||||
|
|
||||||
|
documents_tables = [
|
||||||
|
("engagement_documents", EngagementDocument.__table__),
|
||||||
|
("engagement_document_versions", EngagementDocumentVersion.__table__),
|
||||||
|
("document_access_logs", DocumentAccessLog.__table__),
|
||||||
|
]
|
||||||
|
for table_name, table in documents_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
missing_optional_tables.append(table)
|
||||||
|
if "user_alerts" not in existing_tables:
|
||||||
|
missing_optional_tables.append(UserAlert.__table__)
|
||||||
|
if "financial_years" not in existing_tables:
|
||||||
|
missing_optional_tables.append(FinancialYear.__table__)
|
||||||
|
|
||||||
|
notice_case_tables = [
|
||||||
|
("notice_cases", NoticeCase.__table__),
|
||||||
|
("notice_case_events", NoticeCaseEvent.__table__),
|
||||||
|
("notice_case_hearings", NoticeCaseHearing.__table__),
|
||||||
|
("notice_case_orders", NoticeCaseOrder.__table__),
|
||||||
|
("notice_case_documents", NoticeCaseDocument.__table__),
|
||||||
|
]
|
||||||
|
for table_name, table in notice_case_tables:
|
||||||
|
if table_name not in existing_tables:
|
||||||
|
missing_optional_tables.append(table)
|
||||||
|
if "employees" not in existing_tables:
|
||||||
|
missing_optional_tables.append(Employee.__table__)
|
||||||
|
if "employee_registration_requests" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeRegistrationRequest.__table__)
|
||||||
|
if "employee_attendance" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeAttendance.__table__)
|
||||||
|
if "employee_onboarding_checklist_items" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeOnboardingChecklistItem.__table__)
|
||||||
|
if "employee_onboarding_tasks" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeOnboardingTask.__table__)
|
||||||
|
if "employee_offboarding_requests" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeOffboardingRequest.__table__)
|
||||||
|
if "employee_offboarding_tasks" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeOffboardingTask.__table__)
|
||||||
|
if "employee_salary_structures" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeeSalaryStructure.__table__)
|
||||||
|
if "employee_payroll_runs" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeePayrollRun.__table__)
|
||||||
|
if "employee_payslips" not in existing_tables and "employees" in existing_tables:
|
||||||
|
missing_optional_tables.append(EmployeePayslip.__table__)
|
||||||
|
if "consultant_workspaces" not in existing_tables and "consultant_profiles" in existing_tables:
|
||||||
|
missing_optional_tables.append(ConsultantWorkspace.__table__)
|
||||||
|
if "consultant_service_requests" not in existing_tables and "consultant_profiles" in existing_tables:
|
||||||
|
missing_optional_tables.append(ConsultantServiceRequest.__table__)
|
||||||
|
if missing_optional_tables:
|
||||||
|
CommonBase.metadata.create_all(bind=CommonEngine, tables=missing_optional_tables)
|
||||||
|
|
||||||
|
if not required_tables.issubset(existing_tables):
|
||||||
|
raise RuntimeError("Database schema is not initialized. Run 'alembic upgrade head' first.")
|
||||||
|
|
||||||
|
_ensure_user_lifecycle_columns()
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
tenant = db.execute(select(Tenant).where(Tenant.code == s.DEFAULT_TENANT_CODE)).scalar_one_or_none()
|
||||||
|
if not tenant:
|
||||||
|
tenant = Tenant(code=s.DEFAULT_TENANT_CODE, name="Default Tenant", is_active=True)
|
||||||
|
db.add(tenant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tenant)
|
||||||
|
|
||||||
|
branch = db.execute(
|
||||||
|
select(Branch).where(Branch.tenant_id == tenant.id, Branch.code == s.DEFAULT_BRANCH_CODE)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not branch:
|
||||||
|
branch = Branch(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
code=s.DEFAULT_BRANCH_CODE,
|
||||||
|
name="Main Branch",
|
||||||
|
timezone=s.DEFAULT_TIMEZONE,
|
||||||
|
is_active=True,
|
||||||
|
allow_login=True,
|
||||||
|
)
|
||||||
|
db.add(branch)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(branch)
|
||||||
|
|
||||||
|
bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none()
|
||||||
|
if not bs:
|
||||||
|
bs = BranchSettings(branch_id=branch.id)
|
||||||
|
db.add(bs)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_ensure_financial_years_for_all_tenants(db, s.DEFAULT_YEAR_CODE)
|
||||||
|
|
||||||
|
for legacy_name, new_name in LEGACY_ROLE_RENAMES.items():
|
||||||
|
legacy_role = db.execute(select(Role).where(Role.name == legacy_name)).scalar_one_or_none()
|
||||||
|
target_role = db.execute(select(Role).where(Role.name == new_name)).scalar_one_or_none()
|
||||||
|
if legacy_role and not target_role:
|
||||||
|
legacy_role.name = new_name
|
||||||
|
elif legacy_role and target_role:
|
||||||
|
for user_role in db.execute(
|
||||||
|
select(UserRole).where(UserRole.role_id == legacy_role.id)
|
||||||
|
).scalars().all():
|
||||||
|
exists = db.execute(
|
||||||
|
select(UserRole).where(
|
||||||
|
UserRole.user_id == user_role.user_id,
|
||||||
|
UserRole.role_id == target_role.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not exists:
|
||||||
|
db.add(UserRole(user_id=user_role.user_id, role_id=target_role.id))
|
||||||
|
db.flush()
|
||||||
|
db.delete(legacy_role)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for role_name in DEFAULT_ROLES:
|
||||||
|
exists = db.execute(select(Role).where(Role.name == role_name)).scalar_one_or_none()
|
||||||
|
if not exists:
|
||||||
|
db.add(Role(name=role_name, is_active=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for code, name in DEFAULT_PERMISSIONS:
|
||||||
|
exists = db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none()
|
||||||
|
if not exists:
|
||||||
|
db.add(Permission(code=code, name=name, is_active=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
roles = {r.name: r for r in db.execute(select(Role)).scalars().all()}
|
||||||
|
permissions = {p.code: p for p in db.execute(select(Permission)).scalars().all()}
|
||||||
|
|
||||||
|
for role_name, permission_codes in ROLE_PERMISSION_MAP.items():
|
||||||
|
role = roles.get(role_name)
|
||||||
|
if not role:
|
||||||
|
continue
|
||||||
|
|
||||||
|
permission_codes = list(dict.fromkeys(permission_codes))
|
||||||
|
|
||||||
|
for code in permission_codes:
|
||||||
|
permission = permissions.get(code)
|
||||||
|
if not permission:
|
||||||
|
continue
|
||||||
|
|
||||||
|
exists = db.execute(
|
||||||
|
select(RolePermission).where(
|
||||||
|
RolePermission.role_id == role.id,
|
||||||
|
RolePermission.permission_id == permission.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
db.add(RolePermission(role_id=role.id, permission_id=permission.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Enforce the updated billing privilege matrix for existing databases.
|
||||||
|
# This removes stale billing permissions from System Admin and grants
|
||||||
|
# Partner own-client billing privileges without altering other modules.
|
||||||
|
billing_permissions = {
|
||||||
|
code: permissions[code]
|
||||||
|
for code in BILLING_PERMISSION_CODES
|
||||||
|
if code in permissions
|
||||||
|
}
|
||||||
|
for role_name, allowed_codes in BILLING_ROLE_PERMISSION_SYNC.items():
|
||||||
|
role = roles.get(role_name)
|
||||||
|
if not role:
|
||||||
|
continue
|
||||||
|
|
||||||
|
allowed_permission_ids = {
|
||||||
|
billing_permissions[code].id
|
||||||
|
for code in allowed_codes
|
||||||
|
if code in billing_permissions
|
||||||
|
}
|
||||||
|
billing_permission_ids = {permission.id for permission in billing_permissions.values()}
|
||||||
|
|
||||||
|
existing_links = db.execute(
|
||||||
|
select(RolePermission).where(
|
||||||
|
RolePermission.role_id == role.id,
|
||||||
|
RolePermission.permission_id.in_(billing_permission_ids),
|
||||||
|
)
|
||||||
|
).scalars().all() if billing_permission_ids else []
|
||||||
|
|
||||||
|
existing_ids = {link.permission_id for link in existing_links}
|
||||||
|
for link in existing_links:
|
||||||
|
if link.permission_id not in allowed_permission_ids:
|
||||||
|
db.delete(link)
|
||||||
|
|
||||||
|
for permission_id in allowed_permission_ids - existing_ids:
|
||||||
|
db.add(RolePermission(role_id=role.id, permission_id=permission_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
any_user = db.execute(select(User.id)).first()
|
||||||
|
if not any_user:
|
||||||
|
admin = User(
|
||||||
|
email=s.BOOTSTRAP_ADMIN_EMAIL,
|
||||||
|
full_name="System Admin",
|
||||||
|
password_hash=hash_password(s.BOOTSTRAP_ADMIN_PASSWORD),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
branch_id=branch.id,
|
||||||
|
is_active=True,
|
||||||
|
allow_login=True,
|
||||||
|
is_locked=False,
|
||||||
|
deleted_at=None,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
|
||||||
|
if roles.get("System Admin"):
|
||||||
|
exists = db.execute(
|
||||||
|
select(UserRole).where(
|
||||||
|
UserRole.user_id == admin.id,
|
||||||
|
UserRole.role_id == roles["System Admin"].id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not exists:
|
||||||
|
db.add(UserRole(user_id=admin.id, role_id=roles["System Admin"].id))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Phase 7O: start alert notification/escalation automation after schema and seed checks.
|
||||||
|
start_notification_scheduler()
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants
|
||||||
|
from app.modules.core.rbac.ui_permissions import (
|
||||||
|
can_change_branch_tenant,
|
||||||
|
can_export_clients,
|
||||||
|
can_import_service_tasks,
|
||||||
|
can_import_services,
|
||||||
|
can_manage_branches,
|
||||||
|
can_manage_clients,
|
||||||
|
can_manage_rbac,
|
||||||
|
can_manage_service_tasks,
|
||||||
|
can_manage_services,
|
||||||
|
can_manage_settings,
|
||||||
|
can_manage_tenants,
|
||||||
|
can_manage_users,
|
||||||
|
can_view_employee_dashboard,
|
||||||
|
can_view_employees,
|
||||||
|
can_manage_employees,
|
||||||
|
can_change_employee_status,
|
||||||
|
can_switch_employee_tenant,
|
||||||
|
can_switch_employee_branch,
|
||||||
|
can_view_employee_portal,
|
||||||
|
can_edit_own_employee_profile,
|
||||||
|
can_view_own_employee_work,
|
||||||
|
can_manage_employee_work,
|
||||||
|
can_view_employee_progress,
|
||||||
|
can_request_employee_registration,
|
||||||
|
can_approve_employee_registrations,
|
||||||
|
can_punch_employee_attendance,
|
||||||
|
can_view_own_employee_attendance,
|
||||||
|
can_view_all_employee_attendance,
|
||||||
|
can_approve_employee_attendance,
|
||||||
|
can_apply_employee_leave,
|
||||||
|
can_view_own_employee_leave,
|
||||||
|
can_view_all_employee_leave,
|
||||||
|
can_approve_employee_leave,
|
||||||
|
can_manage_employee_leave_types,
|
||||||
|
can_manage_employee_leave_balances,
|
||||||
|
can_view_own_employee_documents,
|
||||||
|
can_upload_own_employee_documents,
|
||||||
|
can_view_all_employee_documents,
|
||||||
|
can_manage_employee_documents,
|
||||||
|
can_verify_employee_documents,
|
||||||
|
can_manage_employee_document_types,
|
||||||
|
can_view_employee_onboarding,
|
||||||
|
can_manage_employee_onboarding,
|
||||||
|
can_approve_employee_onboarding,
|
||||||
|
can_view_employee_offboarding,
|
||||||
|
can_manage_employee_offboarding,
|
||||||
|
can_approve_employee_offboarding,
|
||||||
|
can_request_own_employee_offboarding,
|
||||||
|
can_import_employee_hr,
|
||||||
|
can_manage_employee_payroll_structures,
|
||||||
|
can_run_employee_payroll,
|
||||||
|
can_view_employee_payroll,
|
||||||
|
can_view_own_employee_payslips,
|
||||||
|
can_approve_employee_payroll,
|
||||||
|
can_view_consultants,
|
||||||
|
can_manage_consultants,
|
||||||
|
can_link_consultant_clients,
|
||||||
|
can_manage_consultant_service_requests,
|
||||||
|
can_manage_consultant_conversions,
|
||||||
|
can_view_consultant_portal,
|
||||||
|
can_manage_own_consultant_workspace,
|
||||||
|
can_switch_client_branch,
|
||||||
|
can_switch_client_tenant,
|
||||||
|
can_switch_service_branch,
|
||||||
|
can_switch_service_tenant,
|
||||||
|
can_view_audit,
|
||||||
|
can_view_branches,
|
||||||
|
can_view_clients,
|
||||||
|
can_view_billing,
|
||||||
|
can_create_billing,
|
||||||
|
can_generate_billing_invoices,
|
||||||
|
can_view_billing_fee_structure,
|
||||||
|
can_import_billing_fee_structure,
|
||||||
|
can_view_platform_billing,
|
||||||
|
can_manage_platform_billing,
|
||||||
|
can_generate_platform_billing,
|
||||||
|
can_manage_platform_plans,
|
||||||
|
can_manage_platform_subscriptions,
|
||||||
|
can_view_marketplace_leads,
|
||||||
|
can_create_marketplace_leads,
|
||||||
|
can_assign_marketplace_leads,
|
||||||
|
can_update_marketplace_leads,
|
||||||
|
can_convert_marketplace_leads,
|
||||||
|
can_view_documents,
|
||||||
|
can_upload_documents,
|
||||||
|
can_download_documents,
|
||||||
|
can_delete_documents,
|
||||||
|
can_view_rbac,
|
||||||
|
can_view_services,
|
||||||
|
can_view_settings,
|
||||||
|
can_view_tenants,
|
||||||
|
can_view_users,
|
||||||
|
can_view_own_alerts,
|
||||||
|
can_manage_alerts,
|
||||||
|
can_view_notice_cases,
|
||||||
|
can_manage_notice_cases,
|
||||||
|
can_upload_notice_case_documents,
|
||||||
|
can_download_notice_case_documents,
|
||||||
|
can_delete_notice_case_documents,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_page_url(base_url: str, page: int, query: str | None = None) -> str:
|
||||||
|
params = dict(parse_qsl((query or "").lstrip("?"), keep_blank_values=True))
|
||||||
|
params["page"] = str(page)
|
||||||
|
qs = urlencode(params)
|
||||||
|
return f"{base_url}?{qs}" if qs else base_url
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_tenant_id(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
return request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_branch_id(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
# return None when "all branches" context is active
|
||||||
|
val = request.session.get("active_branch_id")
|
||||||
|
if val in (None, "", 0, "0"):
|
||||||
|
return None
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_tenant_code(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
return request.session.get("active_tenant_code") or request.session.get("tenant_code") or getattr(request.state, "tenant_code", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_branch_code(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
val = request.session.get("active_branch_code") or request.session.get("branch_code")
|
||||||
|
return val or getattr(request.state, "branch_code", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_financial_year(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
return request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_assessment_year(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
fy_code = get_active_financial_year(request, current_user)
|
||||||
|
if not fy_code:
|
||||||
|
return None
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.core.tenancy.models import FinancialYear
|
||||||
|
tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None)
|
||||||
|
fy = db.execute(
|
||||||
|
select(FinancialYear).where(
|
||||||
|
FinancialYear.tenant_id == tenant_id,
|
||||||
|
FinancialYear.year_code == fy_code,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return fy.assessment_year if fy else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_unread_alert_count(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
from app.modules.alerts.service import count_unread_alerts
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
return count_unread_alerts(db, current_user)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_static_path(path: str | None) -> str | None:
|
||||||
|
path = (path or "").strip()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
if path.startswith("/static/"):
|
||||||
|
return path
|
||||||
|
if path.startswith("app/ui/static/"):
|
||||||
|
return "/static/" + path.split("app/ui/static/", 1)[1]
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_domain_context(request) -> dict:
|
||||||
|
"""Return safe domain context populated by Phase 7T.2 middleware."""
|
||||||
|
try:
|
||||||
|
ctx = getattr(request.state, "domain_context", None)
|
||||||
|
return ctx if isinstance(ctx, dict) else {"is_resolved": False}
|
||||||
|
except Exception:
|
||||||
|
return {"is_resolved": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _branding_default() -> dict:
|
||||||
|
return {
|
||||||
|
"firm_name": "Audit Firm ERP",
|
||||||
|
"branch_name": "",
|
||||||
|
"logo_url": None,
|
||||||
|
"favicon_url": None,
|
||||||
|
"primary_color": "#2563eb",
|
||||||
|
"accent_color": "#0f172a",
|
||||||
|
"contact_email": None,
|
||||||
|
"contact_mobile": None,
|
||||||
|
"website_url": None,
|
||||||
|
"domain_name": None,
|
||||||
|
"domain_type": None,
|
||||||
|
"domain_resolved": False,
|
||||||
|
"is_marketplace_domain": False,
|
||||||
|
"is_consultant_domain": False,
|
||||||
|
"consultant_name": None,
|
||||||
|
"consultant_firm_name": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_branding_from_row(tenant, branch=None, default: dict | None = None) -> dict:
|
||||||
|
default = default or _branding_default()
|
||||||
|
if not tenant:
|
||||||
|
return default.copy()
|
||||||
|
return {
|
||||||
|
**default,
|
||||||
|
"firm_name": getattr(tenant, "display_name", None) or getattr(tenant, "name", None) or default["firm_name"],
|
||||||
|
"branch_name": getattr(branch, "name", None) if branch else "All Branches",
|
||||||
|
"logo_url": _safe_static_path(getattr(tenant, "logo_path", None)),
|
||||||
|
"favicon_url": _safe_static_path(getattr(tenant, "favicon_path", None)),
|
||||||
|
"primary_color": getattr(tenant, "primary_color", None) or default["primary_color"],
|
||||||
|
"accent_color": getattr(tenant, "accent_color", None) or default["accent_color"],
|
||||||
|
"contact_email": getattr(tenant, "contact_email", None),
|
||||||
|
"contact_mobile": getattr(tenant, "contact_mobile", None),
|
||||||
|
"website_url": getattr(tenant, "website_url", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_branding(request, default: dict | None = None) -> dict:
|
||||||
|
default = default or _branding_default()
|
||||||
|
ctx = get_domain_context(request)
|
||||||
|
if not ctx.get("is_resolved"):
|
||||||
|
return default.copy()
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.core.tenancy.models import Branch, Tenant
|
||||||
|
from app.modules.consultants.models import ConsultantProfile
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
domain_type = ctx.get("domain_type")
|
||||||
|
tenant_id = ctx.get("tenant_id") or ctx.get("parent_tenant_id")
|
||||||
|
branch_id = ctx.get("branch_id")
|
||||||
|
consultant_id = ctx.get("consultant_id")
|
||||||
|
|
||||||
|
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() if tenant_id else None
|
||||||
|
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None
|
||||||
|
branding = _tenant_branding_from_row(tenant, branch, default)
|
||||||
|
branding.update({
|
||||||
|
"domain_name": ctx.get("domain_name") or ctx.get("host"),
|
||||||
|
"domain_type": domain_type,
|
||||||
|
"domain_resolved": True,
|
||||||
|
"is_marketplace_domain": domain_type == "marketplace",
|
||||||
|
"is_consultant_domain": str(domain_type or "").startswith("consultant_"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if domain_type == "marketplace":
|
||||||
|
branding["firm_name"] = "FilingABC"
|
||||||
|
branding["branch_name"] = "Marketplace"
|
||||||
|
return branding
|
||||||
|
|
||||||
|
if consultant_id:
|
||||||
|
consultant = db.execute(select(ConsultantProfile).where(ConsultantProfile.id == consultant_id)).scalar_one_or_none()
|
||||||
|
if consultant:
|
||||||
|
consultant_name = getattr(consultant, "contact_person", None) or getattr(consultant, "firm_name", None) or "Consultant"
|
||||||
|
consultant_firm_name = getattr(consultant, "firm_name", None) or consultant_name
|
||||||
|
branding["consultant_name"] = consultant_name
|
||||||
|
branding["consultant_firm_name"] = consultant_firm_name
|
||||||
|
branding["firm_name"] = consultant_firm_name
|
||||||
|
branding["branch_name"] = "Consultant Workspace"
|
||||||
|
branding["contact_email"] = getattr(consultant, "email", None) or branding.get("contact_email")
|
||||||
|
branding["contact_mobile"] = getattr(consultant, "mobile", None) or branding.get("contact_mobile")
|
||||||
|
|
||||||
|
# If the consultant user has a profile photo, use it as the domain logo.
|
||||||
|
user_id = getattr(consultant, "user_id", None)
|
||||||
|
if user_id:
|
||||||
|
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||||
|
logo = _safe_static_path(getattr(user, "profile_photo_path", None))
|
||||||
|
if logo:
|
||||||
|
branding["logo_url"] = logo
|
||||||
|
return branding
|
||||||
|
except Exception:
|
||||||
|
return default.copy()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tenant_name(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return _domain_branding(request).get("firm_name") or "Audit Firm"
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.core.tenancy.models import Tenant
|
||||||
|
tenant_id = get_active_tenant_id(request, current_user)
|
||||||
|
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none()
|
||||||
|
if not tenant:
|
||||||
|
return _domain_branding(request).get("firm_name") or "Audit Firm"
|
||||||
|
return getattr(tenant, "display_name", None) or tenant.name or "Audit Firm"
|
||||||
|
except Exception:
|
||||||
|
return _domain_branding(request).get("firm_name") or "Audit Firm"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_branch_name(request, current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return _domain_branding(request).get("branch_name") or "-"
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.core.tenancy.models import Branch
|
||||||
|
branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None)
|
||||||
|
if not branch_id:
|
||||||
|
return "All Branches"
|
||||||
|
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none()
|
||||||
|
return branch.name if branch else "-"
|
||||||
|
except Exception:
|
||||||
|
return "-"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_firm_branding(request, current_user=None):
|
||||||
|
default = _branding_default()
|
||||||
|
|
||||||
|
# Before login, domain branding is the only safe branding source. This supports
|
||||||
|
# arrr.associates, auditfirm.filingabc.com, filingabc.com and consultant domains.
|
||||||
|
if not current_user:
|
||||||
|
return _domain_branding(request, default)
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.core.tenancy.models import Branch, Tenant
|
||||||
|
tenant_id = get_active_tenant_id(request, current_user)
|
||||||
|
branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None)
|
||||||
|
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none()
|
||||||
|
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None
|
||||||
|
if not tenant:
|
||||||
|
return _domain_branding(request, default)
|
||||||
|
branding = _tenant_branding_from_row(tenant, branch, default)
|
||||||
|
ctx = get_domain_context(request)
|
||||||
|
if ctx.get("is_resolved"):
|
||||||
|
branding.update({
|
||||||
|
"domain_name": ctx.get("domain_name") or ctx.get("host"),
|
||||||
|
"domain_type": ctx.get("domain_type"),
|
||||||
|
"domain_resolved": True,
|
||||||
|
"is_marketplace_domain": ctx.get("domain_type") == "marketplace",
|
||||||
|
"is_consultant_domain": str(ctx.get("domain_type") or "").startswith("consultant_"),
|
||||||
|
})
|
||||||
|
return branding
|
||||||
|
except Exception:
|
||||||
|
return _domain_branding(request, default)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile_photo_url(current_user=None):
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from app.modules.core.iam.profile_service import profile_photo_url
|
||||||
|
return profile_photo_url(current_user)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_initials(current_user=None):
|
||||||
|
try:
|
||||||
|
from app.modules.core.iam.profile_service import user_initials
|
||||||
|
return user_initials(current_user)
|
||||||
|
except Exception:
|
||||||
|
return "U"
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_sidebar_auditor_card(request, current_user=None):
|
||||||
|
"""Return the client-facing auditor card for the logged-in client user.
|
||||||
|
|
||||||
|
This is used only by the sidebar. It reuses Phase 7Q.5 auditor_service and
|
||||||
|
does not create or alter any business workflow.
|
||||||
|
"""
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.clients.auditor_service import build_client_auditor_card
|
||||||
|
from app.modules.clients.models import Client
|
||||||
|
from app.modules.core.tenancy.models import Branch, Tenant
|
||||||
|
|
||||||
|
tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None)
|
||||||
|
email = (getattr(current_user, "email", None) or "").strip().lower()
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Client, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
|
||||||
|
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||||
|
.join(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||||
|
.where(Client.is_active.is_(True), Client.is_archived.is_(False))
|
||||||
|
)
|
||||||
|
if tenant_id:
|
||||||
|
stmt = stmt.where(Client.tenant_id == int(tenant_id))
|
||||||
|
if email:
|
||||||
|
stmt = stmt.where((Client.portal_user_id == current_user.id) | (Client.email == email) | (Client.alternate_email == email))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Client.portal_user_id == current_user.id)
|
||||||
|
|
||||||
|
result = db.execute(stmt.order_by(Client.id.desc())).first()
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
client, tenant_name, branch_name = result
|
||||||
|
client_row = {
|
||||||
|
"id": client.id,
|
||||||
|
"tenant_id": client.tenant_id,
|
||||||
|
"branch_id": client.branch_id,
|
||||||
|
"tenant_name": tenant_name,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"partner_id": client.partner_id,
|
||||||
|
"default_review_partner_user_id": client.default_review_partner_user_id,
|
||||||
|
}
|
||||||
|
return build_client_auditor_card(db, client_row)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_context_tenants(request, current_user=None, permissions=None, role_names=None):
|
||||||
|
if not current_user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not (
|
||||||
|
can_switch_service_tenant(current_user, permissions, role_names)
|
||||||
|
or can_switch_client_tenant(current_user, permissions, role_names)
|
||||||
|
or can_switch_employee_tenant(current_user, permissions, role_names)
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
scope = build_scope(db, current_user)
|
||||||
|
return list_visible_tenants(db, scope)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_branches(request, current_user=None, permissions=None, role_names=None):
|
||||||
|
if not current_user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not (
|
||||||
|
can_switch_service_branch(current_user, permissions, role_names)
|
||||||
|
or can_switch_client_branch(current_user, permissions, role_names)
|
||||||
|
or can_switch_employee_branch(current_user, permissions, role_names)
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
scope = build_scope(db, current_user)
|
||||||
|
tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id)
|
||||||
|
return list_visible_branches(db, scope, tenant_id=tenant_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_context_financial_years(request, current_user=None, permissions=None, role_names=None):
|
||||||
|
if not current_user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.modules.core.tenancy.models import FinancialYear
|
||||||
|
tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id)
|
||||||
|
return db.execute(
|
||||||
|
select(FinancialYear)
|
||||||
|
.where(FinancialYear.tenant_id == tenant_id)
|
||||||
|
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
|
||||||
|
).scalars().all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.globals.update(
|
||||||
|
can_view_users=can_view_users,
|
||||||
|
can_view_own_alerts=can_view_own_alerts,
|
||||||
|
can_manage_alerts=can_manage_alerts,
|
||||||
|
can_view_notice_cases=can_view_notice_cases,
|
||||||
|
can_manage_notice_cases=can_manage_notice_cases,
|
||||||
|
can_upload_notice_case_documents=can_upload_notice_case_documents,
|
||||||
|
can_download_notice_case_documents=can_download_notice_case_documents,
|
||||||
|
can_delete_notice_case_documents=can_delete_notice_case_documents,
|
||||||
|
can_view_employee_dashboard=can_view_employee_dashboard,
|
||||||
|
can_view_employees=can_view_employees,
|
||||||
|
can_manage_employees=can_manage_employees,
|
||||||
|
can_change_employee_status=can_change_employee_status,
|
||||||
|
can_switch_employee_tenant=can_switch_employee_tenant,
|
||||||
|
can_switch_employee_branch=can_switch_employee_branch,
|
||||||
|
can_view_employee_portal=can_view_employee_portal,
|
||||||
|
can_edit_own_employee_profile=can_edit_own_employee_profile,
|
||||||
|
can_view_own_employee_work=can_view_own_employee_work,
|
||||||
|
can_manage_employee_work=can_manage_employee_work,
|
||||||
|
can_view_employee_progress=can_view_employee_progress,
|
||||||
|
can_request_employee_registration=can_request_employee_registration,
|
||||||
|
can_approve_employee_registrations=can_approve_employee_registrations,
|
||||||
|
can_punch_employee_attendance=can_punch_employee_attendance,
|
||||||
|
can_view_own_employee_attendance=can_view_own_employee_attendance,
|
||||||
|
can_view_all_employee_attendance=can_view_all_employee_attendance,
|
||||||
|
can_approve_employee_attendance=can_approve_employee_attendance,
|
||||||
|
can_apply_employee_leave=can_apply_employee_leave,
|
||||||
|
can_view_own_employee_leave=can_view_own_employee_leave,
|
||||||
|
can_view_all_employee_leave=can_view_all_employee_leave,
|
||||||
|
can_approve_employee_leave=can_approve_employee_leave,
|
||||||
|
can_manage_employee_leave_types=can_manage_employee_leave_types,
|
||||||
|
can_manage_employee_leave_balances=can_manage_employee_leave_balances,
|
||||||
|
can_view_own_employee_documents=can_view_own_employee_documents,
|
||||||
|
can_upload_own_employee_documents=can_upload_own_employee_documents,
|
||||||
|
can_view_all_employee_documents=can_view_all_employee_documents,
|
||||||
|
can_manage_employee_documents=can_manage_employee_documents,
|
||||||
|
can_verify_employee_documents=can_verify_employee_documents,
|
||||||
|
can_manage_employee_document_types=can_manage_employee_document_types,
|
||||||
|
can_view_employee_onboarding=can_view_employee_onboarding,
|
||||||
|
can_manage_employee_onboarding=can_manage_employee_onboarding,
|
||||||
|
can_approve_employee_onboarding=can_approve_employee_onboarding,
|
||||||
|
can_view_employee_offboarding=can_view_employee_offboarding,
|
||||||
|
can_manage_employee_offboarding=can_manage_employee_offboarding,
|
||||||
|
can_approve_employee_offboarding=can_approve_employee_offboarding,
|
||||||
|
can_request_own_employee_offboarding=can_request_own_employee_offboarding,
|
||||||
|
can_import_employee_hr=can_import_employee_hr,
|
||||||
|
can_manage_employee_payroll_structures=can_manage_employee_payroll_structures,
|
||||||
|
can_run_employee_payroll=can_run_employee_payroll,
|
||||||
|
can_view_employee_payroll=can_view_employee_payroll,
|
||||||
|
can_view_own_employee_payslips=can_view_own_employee_payslips,
|
||||||
|
can_approve_employee_payroll=can_approve_employee_payroll,
|
||||||
|
can_manage_users=can_manage_users,
|
||||||
|
can_view_consultants=can_view_consultants,
|
||||||
|
can_manage_consultants=can_manage_consultants,
|
||||||
|
can_link_consultant_clients=can_link_consultant_clients,
|
||||||
|
can_manage_consultant_service_requests=can_manage_consultant_service_requests,
|
||||||
|
can_manage_consultant_conversions=can_manage_consultant_conversions,
|
||||||
|
can_view_consultant_portal=can_view_consultant_portal,
|
||||||
|
can_manage_own_consultant_workspace=can_manage_own_consultant_workspace,
|
||||||
|
can_view_settings=can_view_settings,
|
||||||
|
can_manage_settings=can_manage_settings,
|
||||||
|
can_view_rbac=can_view_rbac,
|
||||||
|
can_manage_rbac=can_manage_rbac,
|
||||||
|
can_view_audit=can_view_audit,
|
||||||
|
can_view_tenants=can_view_tenants,
|
||||||
|
can_manage_tenants=can_manage_tenants,
|
||||||
|
can_view_branches=can_view_branches,
|
||||||
|
can_manage_branches=can_manage_branches,
|
||||||
|
can_change_branch_tenant=can_change_branch_tenant,
|
||||||
|
can_view_services=can_view_services,
|
||||||
|
can_manage_services=can_manage_services,
|
||||||
|
can_manage_service_tasks=can_manage_service_tasks,
|
||||||
|
can_import_services=can_import_services,
|
||||||
|
can_import_service_tasks=can_import_service_tasks,
|
||||||
|
can_switch_service_tenant=can_switch_service_tenant,
|
||||||
|
can_switch_service_branch=can_switch_service_branch,
|
||||||
|
can_view_clients=can_view_clients,
|
||||||
|
can_view_billing=can_view_billing,
|
||||||
|
can_create_billing=can_create_billing,
|
||||||
|
can_generate_billing_invoices=can_generate_billing_invoices,
|
||||||
|
can_view_billing_fee_structure=can_view_billing_fee_structure,
|
||||||
|
can_import_billing_fee_structure=can_import_billing_fee_structure,
|
||||||
|
can_view_platform_billing=can_view_platform_billing,
|
||||||
|
can_manage_platform_billing=can_manage_platform_billing,
|
||||||
|
can_generate_platform_billing=can_generate_platform_billing,
|
||||||
|
can_manage_platform_plans=can_manage_platform_plans,
|
||||||
|
can_manage_platform_subscriptions=can_manage_platform_subscriptions,
|
||||||
|
can_view_marketplace_leads=can_view_marketplace_leads,
|
||||||
|
can_create_marketplace_leads=can_create_marketplace_leads,
|
||||||
|
can_assign_marketplace_leads=can_assign_marketplace_leads,
|
||||||
|
can_update_marketplace_leads=can_update_marketplace_leads,
|
||||||
|
can_convert_marketplace_leads=can_convert_marketplace_leads,
|
||||||
|
can_view_documents=can_view_documents,
|
||||||
|
can_upload_documents=can_upload_documents,
|
||||||
|
can_download_documents=can_download_documents,
|
||||||
|
can_delete_documents=can_delete_documents,
|
||||||
|
can_manage_clients=can_manage_clients,
|
||||||
|
can_export_clients=can_export_clients,
|
||||||
|
can_switch_client_tenant=can_switch_client_tenant,
|
||||||
|
can_switch_client_branch=can_switch_client_branch,
|
||||||
|
get_unread_alert_count=get_unread_alert_count,
|
||||||
|
get_current_tenant_name=get_current_tenant_name,
|
||||||
|
get_current_branch_name=get_current_branch_name,
|
||||||
|
get_current_firm_branding=get_current_firm_branding,
|
||||||
|
get_domain_context=get_domain_context,
|
||||||
|
get_user_profile_photo_url=get_user_profile_photo_url,
|
||||||
|
get_user_initials=get_user_initials,
|
||||||
|
get_client_sidebar_auditor_card=get_client_sidebar_auditor_card,
|
||||||
|
get_context_tenants=get_context_tenants,
|
||||||
|
get_context_branches=get_context_branches,
|
||||||
|
get_context_financial_years=get_context_financial_years,
|
||||||
|
get_active_tenant_id=get_active_tenant_id,
|
||||||
|
get_active_tenant_code=get_active_tenant_code,
|
||||||
|
get_active_branch_id=get_active_branch_id,
|
||||||
|
get_active_branch_code=get_active_branch_code,
|
||||||
|
get_active_financial_year=get_active_financial_year,
|
||||||
|
get_active_assessment_year=get_active_assessment_year,
|
||||||
|
build_page_url=build_page_url,
|
||||||
|
)
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from app.core.settings import get_settings
|
||||||
|
from app.core.middleware.context import ContextResolveMiddleware
|
||||||
|
from app.core.middleware.domain_resolver import DomainResolverMiddleware
|
||||||
|
from app.core.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
|
from app.core.startup import on_startup
|
||||||
|
from app.core.api import api_router
|
||||||
|
from app.ui.app import mount_ui
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
s = get_settings()
|
||||||
|
app = FastAPI(title=s.APP_NAME, debug=s.DEBUG)
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
app.add_middleware(ContextResolveMiddleware)
|
||||||
|
# Phase 7T.2: added after context so it resolves the request host before
|
||||||
|
# context-aware middleware/routes need tenant/branch/domain state.
|
||||||
|
app.add_middleware(DomainResolverMiddleware)
|
||||||
|
# SessionMiddleware is added last so it is available to downstream
|
||||||
|
# middleware/routes in Starlette's middleware execution order.
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=s.SECRET_KEY,
|
||||||
|
session_cookie=s.COOKIE_SESSION_NAME,
|
||||||
|
same_site=s.COOKIE_SAMESITE,
|
||||||
|
https_only=s.COOKIE_SECURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_event_handler("startup", lambda: on_startup(app))
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
mount_ui(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db.common import CommonBase
|
||||||
|
|
||||||
|
|
||||||
|
class UserAlert(CommonBase):
|
||||||
|
"""Common role-aware alert table for all dashboards and portals.
|
||||||
|
|
||||||
|
Phase 7H foundation only stores and displays alerts. Later phases can call
|
||||||
|
app.modules.alerts.service.create_alert() from task, document, attendance,
|
||||||
|
client and consultant workflows without changing this schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_alerts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
role_context: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
|
alert_type: Mapped[str] = mapped_column(String(80), nullable=False, default="general", index=True)
|
||||||
|
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
target_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
read_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at_utc: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, select, update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.alerts.models import UserAlert
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
from app.modules.email_integration.event_service import send_alert_created_email
|
||||||
|
|
||||||
|
ALERT_PRIORITIES = ("low", "normal", "high", "critical")
|
||||||
|
ALERT_TYPES = (
|
||||||
|
"general",
|
||||||
|
"task_assigned",
|
||||||
|
"task_due",
|
||||||
|
"task_overdue",
|
||||||
|
"task_review",
|
||||||
|
"document_uploaded",
|
||||||
|
"clarification",
|
||||||
|
"attendance",
|
||||||
|
"leave",
|
||||||
|
"payroll",
|
||||||
|
"consultant",
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_priority(priority: str | None) -> str:
|
||||||
|
value = (priority or "normal").strip().lower()
|
||||||
|
return value if value in ALERT_PRIORITIES else "normal"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_alert_type(alert_type: str | None) -> str:
|
||||||
|
value = (alert_type or "general").strip().lower()
|
||||||
|
return value or "general"
|
||||||
|
|
||||||
|
|
||||||
|
def create_alert(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
message: str | None = None,
|
||||||
|
tenant_id: int | None = None,
|
||||||
|
branch_id: int | None = None,
|
||||||
|
role_context: str | None = None,
|
||||||
|
alert_type: str = "general",
|
||||||
|
priority: str = "normal",
|
||||||
|
target_url: str | None = None,
|
||||||
|
created_by_user_id: int | None = None,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> UserAlert:
|
||||||
|
alert = UserAlert(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
user_id=user_id,
|
||||||
|
role_context=(role_context or None),
|
||||||
|
alert_type=normalize_alert_type(alert_type),
|
||||||
|
priority=normalize_priority(priority),
|
||||||
|
title=(title or "Alert").strip()[:255],
|
||||||
|
message=(message or None),
|
||||||
|
target_url=(target_url or None),
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
db.flush()
|
||||||
|
try:
|
||||||
|
send_alert_created_email(db, alert)
|
||||||
|
except Exception:
|
||||||
|
# Email notification must never block in-app alert creation.
|
||||||
|
pass
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(alert)
|
||||||
|
return alert
|
||||||
|
|
||||||
|
|
||||||
|
def create_bulk_alerts(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_ids: Iterable[int],
|
||||||
|
title: str,
|
||||||
|
message: str | None = None,
|
||||||
|
tenant_id: int | None = None,
|
||||||
|
branch_id: int | None = None,
|
||||||
|
role_context: str | None = None,
|
||||||
|
alert_type: str = "general",
|
||||||
|
priority: str = "normal",
|
||||||
|
target_url: str | None = None,
|
||||||
|
created_by_user_id: int | None = None,
|
||||||
|
) -> list[UserAlert]:
|
||||||
|
rows: list[UserAlert] = []
|
||||||
|
for user_id in sorted({int(uid) for uid in user_ids if uid}):
|
||||||
|
rows.append(
|
||||||
|
create_alert(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
role_context=role_context,
|
||||||
|
alert_type=alert_type,
|
||||||
|
priority=priority,
|
||||||
|
target_url=target_url,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
for row in rows:
|
||||||
|
db.refresh(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _user_alert_query(current_user: User) -> Select:
|
||||||
|
return select(UserAlert).where(UserAlert.user_id == current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_my_alerts(
|
||||||
|
db: Session,
|
||||||
|
current_user: User,
|
||||||
|
*,
|
||||||
|
status: str = "all",
|
||||||
|
priority: str = "all",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[UserAlert]:
|
||||||
|
q = _user_alert_query(current_user)
|
||||||
|
if status == "unread":
|
||||||
|
q = q.where(UserAlert.is_read.is_(False))
|
||||||
|
elif status == "read":
|
||||||
|
q = q.where(UserAlert.is_read.is_(True))
|
||||||
|
if priority in ALERT_PRIORITIES:
|
||||||
|
q = q.where(UserAlert.priority == priority)
|
||||||
|
q = q.order_by(UserAlert.is_read.asc(), UserAlert.created_at_utc.desc()).limit(max(1, min(limit, 500)))
|
||||||
|
return list(db.execute(q).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_unread_alerts(db: Session, current_user: User | None) -> int:
|
||||||
|
if not current_user:
|
||||||
|
return 0
|
||||||
|
value = db.execute(
|
||||||
|
select(func.count(UserAlert.id)).where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||||
|
).scalar_one()
|
||||||
|
return int(value or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_alert_or_404(db: Session, current_user: User, alert_id: int) -> UserAlert | None:
|
||||||
|
return db.execute(
|
||||||
|
select(UserAlert).where(UserAlert.id == alert_id, UserAlert.user_id == current_user.id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def mark_alert_read(db: Session, current_user: User, alert_id: int) -> bool:
|
||||||
|
alert = get_my_alert_or_404(db, current_user, alert_id)
|
||||||
|
if not alert:
|
||||||
|
return False
|
||||||
|
if not alert.is_read:
|
||||||
|
alert.is_read = True
|
||||||
|
alert.read_at_utc = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_all_alerts_read(db: Session, current_user: User) -> int:
|
||||||
|
result = db.execute(
|
||||||
|
update(UserAlert)
|
||||||
|
.where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||||
|
.values(is_read=True, read_at_utc=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return int(result.rowcount or 0)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% set _role_text = (current_user_roles or [])|join('|')|lower %}
|
||||||
|
{% if 'partner' in _role_text %}
|
||||||
|
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||||
|
{% elif 'manager' in _role_text %}
|
||||||
|
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">My Alerts</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Role-wise alerts for tasks, documents, attendance, leave, payroll, client and consultant workflows.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/alerts/read-all">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" {% if unread_count == 0 %}disabled{% endif %}>Mark all as read</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Unread</div><div class="mt-1 text-2xl font-semibold">{{ unread_count }}</div></div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Showing</div><div class="mt-1 text-2xl font-semibold">{{ alerts|length }}</div></div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Filter</div><div class="mt-1 text-sm text-slate-600">{{ status.replace('_',' ').title() }} · {{ priority.title() }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="/alerts" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||||
|
<div class="grid gap-3 md:grid-cols-[220px_220px_auto]">
|
||||||
|
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="all" {% if status == 'all' %}selected{% endif %}>All alerts</option>
|
||||||
|
<option value="unread" {% if status == 'unread' %}selected{% endif %}>Unread only</option>
|
||||||
|
<option value="read" {% if status == 'read' %}selected{% endif %}>Read only</option>
|
||||||
|
</select>
|
||||||
|
<select name="priority" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="all" {% if priority == 'all' %}selected{% endif %}>All priorities</option>
|
||||||
|
{% for p in priorities %}<option value="{{ p }}" {% if priority == p %}selected{% endif %}>{{ p.title() }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply Filter</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for alert in alerts %}
|
||||||
|
<div class="rounded-2xl border {% if alert.is_read %}border-slate-200 bg-white{% else %}border-brand-100 bg-brand-50{% endif %} p-5 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="font-semibold text-slate-900">{{ alert.title }}</h3>
|
||||||
|
<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">{{ alert.priority }}</span>
|
||||||
|
{% if not alert.is_read %}<span class="rounded-full bg-brand-600 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">Unread</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">{{ alert.alert_type.replace('_',' ').title() }}{% if alert.role_context %} · {{ alert.role_context }}{% endif %} · {{ alert.created_at_utc.strftime('%d-%m-%Y %H:%M') if alert.created_at_utc else '-' }}</div>
|
||||||
|
{% if alert.message %}<p class="mt-3 text-sm text-slate-700">{{ alert.message }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||||
|
{% if alert.target_url %}<a href="{{ alert.target_url }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open</a>{% endif %}
|
||||||
|
{% if not alert.is_read %}
|
||||||
|
<form method="post" action="/alerts/{{ alert.id }}/read">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Mark read</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No alerts found for the selected filter.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||||
|
from app.core.security.session_auth import get_current_user
|
||||||
|
from app.core.templating import templates
|
||||||
|
from app.modules.alerts.service import ALERT_PRIORITIES, count_unread_alerts, list_my_alerts, mark_alert_read, mark_all_alerts_read
|
||||||
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alerts", tags=["alerts-ui"])
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_login():
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_ctx(request: Request, db, current_user, **ctx):
|
||||||
|
base = {
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user,
|
||||||
|
"current_user_roles": get_user_roles(db, current_user.id),
|
||||||
|
"current_user_permissions": get_user_permissions(db, current_user.id),
|
||||||
|
"csrf_token": get_or_create_csrf_token(request),
|
||||||
|
}
|
||||||
|
base.update(ctx)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/poll")
|
||||||
|
def poll_unread_alerts(request: Request, limit: int = 5):
|
||||||
|
"""Lightweight polling endpoint used by the base layout toast popup.
|
||||||
|
|
||||||
|
Returns a small list of unread alerts for the logged-in user. It does not
|
||||||
|
mark alerts as read; the normal /alerts page and existing read actions
|
||||||
|
continue to control read status.
|
||||||
|
"""
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
current_user = get_current_user(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return JSONResponse({"authenticated": False, "unread_count": 0, "alerts": []}, status_code=401)
|
||||||
|
|
||||||
|
safe_limit = max(1, min(int(limit or 5), 10))
|
||||||
|
rows = list_my_alerts(db, current_user, status="unread", priority="all", limit=safe_limit)
|
||||||
|
payload = []
|
||||||
|
for row in rows:
|
||||||
|
created_at = getattr(row, "created_at_utc", None)
|
||||||
|
payload.append(
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title or "Alert",
|
||||||
|
"message": row.message or "",
|
||||||
|
"priority": row.priority or "normal",
|
||||||
|
"alert_type": row.alert_type or "general",
|
||||||
|
"target_url": row.target_url or "/alerts",
|
||||||
|
"created_at_utc": created_at.isoformat() if created_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"authenticated": True,
|
||||||
|
"unread_count": count_unread_alerts(db, current_user),
|
||||||
|
"alerts": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def alerts_list(request: Request, status: str = "all", priority: str = "all"):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
current_user = get_current_user(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return _redirect_login()
|
||||||
|
status = status if status in {"all", "unread", "read"} else "all"
|
||||||
|
priority = priority if priority in ALERT_PRIORITIES else "all"
|
||||||
|
rows = list_my_alerts(db, current_user, status=status, priority=priority, limit=150)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/alerts/templates/alerts/list.html",
|
||||||
|
_base_ctx(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
title="My Alerts",
|
||||||
|
alerts=rows,
|
||||||
|
status=status,
|
||||||
|
priority=priority,
|
||||||
|
priorities=ALERT_PRIORITIES,
|
||||||
|
unread_count=count_unread_alerts(db, current_user),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/read")
|
||||||
|
def mark_read(request: Request, alert_id: int, csrf_token: str = Form(...)):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
current_user = get_current_user(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return _redirect_login()
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
mark_alert_read(db, current_user, alert_id)
|
||||||
|
return RedirectResponse(url="/alerts", status_code=303)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/read-all")
|
||||||
|
def mark_all_read(request: Request, csrf_token: str = Form(...)):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
current_user = get_current_user(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return _redirect_login()
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
mark_all_alerts_read(db, current_user)
|
||||||
|
return RedirectResponse(url="/alerts", status_code=303)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Billing module for firm-level invoices and fee structure imports."""
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user