latest code
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.pytype/
|
||||
.pyre/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
storage_drive/
|
||||
storage/
|
||||
uploads/
|
||||
downloads/
|
||||
temp/
|
||||
tmp/
|
||||
Dockerfile.*
|
||||
docker-compose*.yml
|
||||
.docker/
|
||||
*.log
|
||||
logs/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
redis_data/
|
||||
qdrant_data/
|
||||
clamav_data/
|
||||
*.md
|
||||
requirements*.txt
|
||||
openapi.json
|
||||
.pre-commit-config.yaml
|
||||
@@ -0,0 +1,68 @@
|
||||
APP_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=20001
|
||||
DEBUG=False
|
||||
DB_HOST=106.51.105.22
|
||||
DB_PORT=5432
|
||||
DB_NAME=docqube_development
|
||||
DB_USER=docqube_user
|
||||
DB_PASSWORD=d20kMfypQE8Z
|
||||
DB_SSLMODE=disable
|
||||
PGBOUNCER_PORT=6432
|
||||
SAAS_TRUST_SECRET=docqube_trust_secret_2026
|
||||
SAAS_BASE_URL=http://localhost:8000
|
||||
SAAS_PUBLIC_KEY=""
|
||||
APP_SECRET=Pk7rcnYeSE-9vgTuevzfvKJ5YAs6Bj0QjgFxyJW00oKMnmPlRxUfU8lAm33GAgxL
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=JVCzB612F573hYwqZZ
|
||||
CLAMAV_HOST=clamav
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_TIMEOUT_SECONDS=20
|
||||
CLAMAV_CHUNK_SIZE=1048576
|
||||
STORAGE_DRIVE_DIR=storage_drive
|
||||
API_BASE_URL=https://docqube.maskantech.in
|
||||
GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
DEEPSEEK_API_KEY=sk-95235bd6d3b440d6b6e25bd722813f3c
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
LLM_TEMPERATURE=0.35
|
||||
MAX_RESPONSE_TOKENS=1500
|
||||
HF_HUB_OFFLINE=0
|
||||
TRANSFORMERS_OFFLINE=0
|
||||
VECTOR_DB=qdrant
|
||||
QDRANT_HOST=qdrant
|
||||
QDRANT_PORT=6333
|
||||
QDRANT_COLLECTION=docqube_collection
|
||||
RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2
|
||||
EMBEDDING_MODEL=BAAI/bge-base-en-v1.5
|
||||
CHUNK_SIZE=850
|
||||
CHUNK_OVERLAP=180
|
||||
TOP_K_QA=3
|
||||
TOP_K_SUMMARY=4
|
||||
MAX_CONTEXT_CHARS=4000
|
||||
MIN_SIMILARITY_SCORE=0.18
|
||||
LOG_LEVEL=WARNING
|
||||
CORS_ORIGINS=https://docqube.maskantech.in
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
MAIL_FROM=info.maskantech@gmail.com
|
||||
SMTP_SECURE=True
|
||||
FRONTEND_URL=https://docqube.maskantech.in
|
||||
CHAT_REDIS_MAX_MESSAGES=10
|
||||
CHAT_REDIS_TTL_SECONDS=86400
|
||||
CHAT_DAILY_CREDITS_LIMIT=1000
|
||||
DOCUSEAL_API_URL=https://api.docuseal.com
|
||||
DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz
|
||||
DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com
|
||||
ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K"
|
||||
ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93"
|
||||
ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d"
|
||||
ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc"
|
||||
ZOHO_DOMAIN="in"
|
||||
MAX_ACTIVE_DEVICES=5
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Environment Configuration
|
||||
APP_ENV=localdev
|
||||
|
||||
# Server Configuration
|
||||
HOST=127.0.0.1
|
||||
PORT=20001
|
||||
DEBUG=True
|
||||
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_NAME=new-base
|
||||
# DB_USER=postgres
|
||||
# DB_PASSWORD=ribai123
|
||||
# DB_SSLMODE=disable
|
||||
# PGBOUNCER_PORT=5432
|
||||
DB_HOST=106.51.105.22
|
||||
DB_PORT=5432
|
||||
DB_NAME=docqube_local
|
||||
DB_USER=docqube_user
|
||||
DB_PASSWORD=d20kMfypQE8Z
|
||||
DB_SSLMODE=disable
|
||||
PGBOUNCER_PORT=6432
|
||||
|
||||
# Security Configuration
|
||||
APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6388
|
||||
REDIS_PASSWORD=JVCzB612F573hYwqZZ
|
||||
|
||||
CLAMAV_HOST=106.51.105.22
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_TIMEOUT_SECONDS=20
|
||||
CLAMAV_CHUNK_SIZE=1048576
|
||||
STORAGE_DRIVE_DIR=storage_drive
|
||||
API_BASE_URL=http://localhost:20001
|
||||
|
||||
|
||||
#Saas
|
||||
SAAS_TRUST_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA
|
||||
SAAS_BASE_URL=http://localhost:8000
|
||||
SAAS_PUBLIC_KEY=""
|
||||
|
||||
# Google Auth Configuration
|
||||
GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
|
||||
# AI & Models Configuration
|
||||
# =========================
|
||||
# AI (DeepSeek)
|
||||
# =========================
|
||||
DEEPSEEK_API_KEY=sk-95235bd6d3b440d6b6e25bd722813f3c
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
LLM_TEMPERATURE=0.35
|
||||
MAX_RESPONSE_TOKENS=1500
|
||||
|
||||
# Extraction / NVIDIA AI Configuration
|
||||
EXTRACTION_API_KEY="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"
|
||||
EXTRACTION_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
EXTRACTION_MODEL=mistralai/mistral-medium-3-instruct
|
||||
EXTRACTION_VISION_MODEL=meta/llama-3.2-90b-vision-instruct
|
||||
|
||||
|
||||
# Force Offline Mode for local models (Prevents startup delays/HF checks)
|
||||
HF_HUB_OFFLINE=0
|
||||
TRANSFORMERS_OFFLINE=0
|
||||
|
||||
# Vector DB Configuration
|
||||
VECTOR_DB=qdrant
|
||||
QDRANT_HOST=localhost
|
||||
QDRANT_PORT=6333
|
||||
QDRANT_COLLECTION=docqube_collection
|
||||
RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2
|
||||
|
||||
# =========================
|
||||
# EMBEDDING
|
||||
# =========================
|
||||
EMBEDDING_MODEL=BAAI/bge-base-en-v1.5
|
||||
# RAG SETTINGS
|
||||
# =========================
|
||||
CHUNK_SIZE=850
|
||||
CHUNK_OVERLAP=180
|
||||
TOP_K_QA=3
|
||||
TOP_K_SUMMARY=4
|
||||
MAX_CONTEXT_CHARS=4000
|
||||
MIN_SIMILARITY_SCORE=0.18
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=ftqjghdbthlwpnsh
|
||||
MAIL_FROM=info.maskantech@gmail.com
|
||||
SMTP_SECURE=True
|
||||
|
||||
|
||||
# Frontend Configuration
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# whisper Model Configuration
|
||||
WHISPER_MODEL=small
|
||||
|
||||
|
||||
# Chat Redis Settings
|
||||
CHAT_REDIS_MAX_MESSAGES=10
|
||||
CHAT_REDIS_TTL_SECONDS=86400
|
||||
CHAT_DAILY_CREDITS_LIMIT=1000
|
||||
|
||||
# Docuseal API Settings
|
||||
# DOCUSEAL_API_URL=https://api.docuseal.com
|
||||
# DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz
|
||||
# DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com
|
||||
|
||||
#ZOHO API Settings
|
||||
# ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K"
|
||||
# ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93"
|
||||
# ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d"
|
||||
ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc"
|
||||
ZOHO_DOMAIN="in"
|
||||
MAX_ACTIVE_DEVICES=10
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Environment Configuration
|
||||
APP_ENV=production
|
||||
|
||||
# Server Configuration
|
||||
HOST=127.0.0.1
|
||||
PORT=20001
|
||||
DEBUG=True
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_NAME=prod_docqube
|
||||
DB_USER=prod_user
|
||||
DB_PASSWORD='GO5cA8hK3@47Uq9im44'
|
||||
DB_SSLMODE=disable
|
||||
PGBOUNCER_PORT=6432
|
||||
|
||||
|
||||
#saas
|
||||
SAAS_TRUST_SECRET=docqube_trust_secret_2026
|
||||
SAAS_BASE_URL=http://localhost:8000
|
||||
SAAS_PUBLIC_KEY=""
|
||||
|
||||
# Security Configuration
|
||||
APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=31.57.228.186
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=TP0TmTqyWzBIuvrE
|
||||
|
||||
CLAMAV_HOST=31.57.228.186
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_TIMEOUT_SECONDS=20
|
||||
CLAMAV_CHUNK_SIZE=1048576
|
||||
STORAGE_DRIVE_DIR=storage_drive
|
||||
API_BASE_URL=https://api.docqube.com
|
||||
|
||||
# Google Auth Configuration
|
||||
GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
|
||||
|
||||
# =========================
|
||||
# AI (DeepSeek)
|
||||
# =========================
|
||||
DEEPSEEK_API_KEY=sk-f51cbc9eca6b466d949bfdd60a892a97
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# =========================
|
||||
# RAG
|
||||
# =========================
|
||||
EMBEDDING_MODEL=BAAI/bge-base-en-v1.5
|
||||
CHUNK_SIZE=300
|
||||
CHUNK_OVERLAP=80
|
||||
TOP_K=3
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=https://docqube.com,https://api.docqube.com
|
||||
COOKIE_DOMAIN=.docqube.com
|
||||
DISABLE_CSRF=True
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=support@docqube.com
|
||||
SMTP_PASSWORD=DQsupport@765$
|
||||
MAIL_FROM=support@docqube.com
|
||||
SMTP_SECURE=True
|
||||
|
||||
# Frontend Configuration
|
||||
FRONTEND_URL=https://docqube.com
|
||||
|
||||
# whisper Model Configuration
|
||||
WHISPER_MODEL=small
|
||||
|
||||
# Chat Redis Settings
|
||||
CHAT_REDIS_MAX_MESSAGES=10
|
||||
CHAT_REDIS_TTL_SECONDS=86400
|
||||
CHAT_DAILY_CREDITS_LIMIT=1000
|
||||
|
||||
|
||||
# Docuseal API Settings
|
||||
DOCUSEAL_API_URL=https://api.docuseal.com
|
||||
DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz
|
||||
DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com
|
||||
|
||||
#ZOHO API Settings
|
||||
ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K"
|
||||
ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93"
|
||||
ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d"
|
||||
ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc"
|
||||
ZOHO_DOMAIN="in"
|
||||
@@ -0,0 +1,68 @@
|
||||
APP_ENV=test
|
||||
HOST=0.0.0.0
|
||||
PORT=20002
|
||||
DEBUG=False
|
||||
DB_HOST=106.51.105.22
|
||||
DB_PORT=5432
|
||||
DB_NAME=docqube_test
|
||||
DB_USER=docqube_user
|
||||
DB_PASSWORD=d20kMfypQE8Z
|
||||
DB_SSLMODE=disable
|
||||
PGBOUNCER_PORT=6432
|
||||
SAAS_TRUST_SECRET=docqube_trust_secret_2026
|
||||
SAAS_BASE_URL=http://localhost:8000
|
||||
SAAS_PUBLIC_KEY=""
|
||||
APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=JVCzB612F573hYwqZZ
|
||||
CLAMAV_HOST=clamav
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_TIMEOUT_SECONDS=20
|
||||
CLAMAV_CHUNK_SIZE=1048576
|
||||
STORAGE_DRIVE_DIR=storage_drive
|
||||
API_BASE_URL=https://docqubeapi-test.maskantech.in
|
||||
GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
DEEPSEEK_API_KEY=sk-43e2e5e6d450447687b3b664f3a91f33
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
LLM_TEMPERATURE=0.35
|
||||
MAX_RESPONSE_TOKENS=1500
|
||||
HF_HUB_OFFLINE=0
|
||||
TRANSFORMERS_OFFLINE=0
|
||||
VECTOR_DB=qdrant
|
||||
QDRANT_HOST=qdrant
|
||||
QDRANT_PORT=6333
|
||||
QDRANT_COLLECTION=docqube_collection
|
||||
RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2
|
||||
EMBEDDING_MODEL=BAAI/bge-base-en-v1.5
|
||||
CHUNK_SIZE=850
|
||||
CHUNK_OVERLAP=180
|
||||
TOP_K_QA=3
|
||||
TOP_K_SUMMARY=4
|
||||
MAX_CONTEXT_CHARS=4000
|
||||
MIN_SIMILARITY_SCORE=0.18
|
||||
LOG_LEVEL=INFO
|
||||
CORS_ORIGINS=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
MAIL_FROM=info.maskantech@gmail.com
|
||||
SMTP_SECURE=True
|
||||
FRONTEND_URL=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in
|
||||
CHAT_REDIS_MAX_MESSAGES=10
|
||||
CHAT_REDIS_TTL_SECONDS=86400
|
||||
CHAT_DAILY_CREDITS_LIMIT=1000
|
||||
DOCUSEAL_API_URL=https://api.docuseal.com
|
||||
DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz
|
||||
DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com
|
||||
ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K"
|
||||
ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93"
|
||||
ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d"
|
||||
ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc"
|
||||
ZOHO_DOMAIN="in"
|
||||
MAX_ACTIVE_DEVICES=5
|
||||
@@ -0,0 +1,68 @@
|
||||
# Environment Configuration
|
||||
APP_ENV=uat
|
||||
|
||||
# Server Configuration
|
||||
HOST=127.0.0.1
|
||||
PORT=20001
|
||||
DEBUG=True
|
||||
|
||||
DB_HOST=106.51.105.22
|
||||
DB_PORT=5432
|
||||
DB_NAME=docqube_uat
|
||||
DB_USER=docqube_user
|
||||
DB_PASSWORD=d20kMfypQE8Z
|
||||
DB_SSLMODE=disable
|
||||
PGBOUNCER_PORT=6432
|
||||
|
||||
# Security Configuration
|
||||
APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6391
|
||||
REDIS_PASSWORD=JVCzB612F573hYwqZZ
|
||||
|
||||
CLAMAV_HOST=106.51.105.22
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_TIMEOUT_SECONDS=20
|
||||
CLAMAV_CHUNK_SIZE=1048576
|
||||
STORAGE_DRIVE_DIR=storage_drive
|
||||
API_BASE_URL=http://localhost:20001
|
||||
|
||||
# Google Auth Configuration
|
||||
GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
|
||||
# AI & Models Configuration
|
||||
OPENAI_API_KEY="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"
|
||||
OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
OPENAI_MODEL=mistralai/mistral-medium-3-instruct
|
||||
OPENAI_VISION_MODEL=meta/llama-3.2-90b-vision-instruct
|
||||
MODEL_SERVER_URL=http://localhost:9000
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
MAIL_FROM=info@maskantech.in
|
||||
SMTP_SECURE=True
|
||||
|
||||
# Frontend Configuration
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# whisper Model Configuration
|
||||
WHISPER_MODEL=small
|
||||
|
||||
# Chat Redis Settings
|
||||
CHAT_REDIS_MAX_MESSAGES=10
|
||||
CHAT_REDIS_TTL_SECONDS=86400
|
||||
CHAT_DAILY_CREDITS_LIMIT=1000
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# Python bytecode and caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env1/
|
||||
|
||||
# Environment files and local secrets
|
||||
!.env.example
|
||||
|
||||
# Test, coverage, and static-analysis output
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytype/
|
||||
.pyre/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
tests/
|
||||
|
||||
# Build and packaging output
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
pip-wheel-metadata/
|
||||
|
||||
# Application runtime data
|
||||
app/temp_uploads/*
|
||||
storage_drive/
|
||||
storage/
|
||||
uploads/
|
||||
downloads/
|
||||
temp/
|
||||
tmp/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Local ML and Hugging Face caches
|
||||
.cache/
|
||||
cache/
|
||||
model_cache/
|
||||
hub/
|
||||
!app/modules/**/models/
|
||||
!app/**/models/
|
||||
|
||||
# Local Docker/service data
|
||||
.docker/
|
||||
redis_data/
|
||||
qdrant_data/
|
||||
clamav_data/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
# Local rework environment — never commit
|
||||
.env.rework
|
||||
.env.rework_test
|
||||
app/temp_uploads/*
|
||||
!app/temp_uploads/.gitkeep
|
||||
.env.rework_rls
|
||||
.gitea/workflows/
|
||||
.github/workflows/
|
||||
@@ -0,0 +1,58 @@
|
||||
# B4.3 — the fast gates, before the commit rather than after the push.
|
||||
#
|
||||
# Only checks that run in about a second belong here. The characterization
|
||||
# suite, the cross-tenant probes and the query budgets need a database and take
|
||||
# a minute; they stay in CI. A pre-commit hook that takes a minute gets
|
||||
# `--no-verify`d, and then none of it runs.
|
||||
#
|
||||
# pip install pre-commit && pre-commit install
|
||||
#
|
||||
# Everything here is also enforced in CI, so an uninstalled hook is a slower
|
||||
# feedback loop rather than a hole.
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=512]
|
||||
- id: detect-private-key
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
# The three rules that are cheap to check and expensive to discover late.
|
||||
- id: no-exception-text-in-responses
|
||||
name: No exception text in HTTP responses
|
||||
entry: python -m pytest tests/probes/test_error_disclosure.py::test_no_exception_text_is_returned_to_callers -q
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^app/.*\.py$
|
||||
description: >
|
||||
`detail=f"...{e}"` sends internal errors to the caller. It reached an
|
||||
unauthenticated endpoint once already.
|
||||
|
||||
- id: transaction-discipline
|
||||
name: Route handlers do not commit
|
||||
entry: python -m pytest tests/probes/test_transaction_discipline.py -q
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^app/.*\.py$
|
||||
description: >
|
||||
The request owns the transaction. A handler that commits splits one
|
||||
request into several and leaves partial state behind on failure.
|
||||
|
||||
- id: layering
|
||||
name: Import layering contracts
|
||||
entry: lint-imports
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^app/.*\.py$
|
||||
description: >
|
||||
routes → controllers → services → repositories → models, one
|
||||
direction only.
|
||||
@@ -0,0 +1 @@
|
||||
3.11.8
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
ARG PYTHON_VERSION=3.11.8
|
||||
|
||||
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.12.8 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libcairo2-dev \
|
||||
libpango1.0-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libffi-dev \
|
||||
libglib2.0-dev \
|
||||
libjpeg-dev \
|
||||
libopenjp2-7-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml uv.lock .python-version ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync \
|
||||
--locked \
|
||||
--no-install-project \
|
||||
--no-editable
|
||||
|
||||
FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libmagic1 \
|
||||
poppler-utils \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi8 \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libjpeg62-turbo \
|
||||
libopenjp2-7 \
|
||||
fonts-dejavu-core \
|
||||
fonts-liberation \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN addgroup --system app && \
|
||||
adduser --system \
|
||||
--ingroup app \
|
||||
--home /home/app \
|
||||
app
|
||||
|
||||
COPY --from=builder --chown=app:app /app/.venv /app/.venv
|
||||
|
||||
COPY --chown=app:app app ./app
|
||||
COPY --chown=app:app manage.py .
|
||||
COPY --chown=app:app alembic ./alembic
|
||||
COPY --chown=app:app alembic.ini .
|
||||
COPY --chown=app:app scripts ./scripts
|
||||
|
||||
USER app
|
||||
|
||||
CMD ["python", "manage.py", "run"]
|
||||
@@ -0,0 +1,426 @@
|
||||
# Docqube Backend
|
||||
|
||||
**FastAPI backend application for Docqube**
|
||||
|
||||
A modern, container-ready backend built with FastAPI, managed by **uv**, and orchestrated with **Docker Compose**.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Python Version](#python-version)
|
||||
- [Installing uv](#installing-uv)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Local Development](#local-development)
|
||||
- [Dependency Management](#dependency-management)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Running Celery Worker](#running-celery-worker)
|
||||
- [Useful uv Commands](#useful-uv-commands)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Source of Truth](#source-of-truth)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Tool | Version / Notes |
|
||||
|-------------------|--------------------------|
|
||||
| **Python** | `3.11.8` (strict) |
|
||||
| **uv** | Latest |
|
||||
| **Docker** | Docker Desktop / Engine |
|
||||
| **Docker Compose**| v2+ |
|
||||
|
||||
---
|
||||
|
||||
## Python Version
|
||||
|
||||
This project is **locked to Python 3.11**.
|
||||
|
||||
```text
|
||||
Python 3.11.8
|
||||
```
|
||||
|
||||
| File | Setting |
|
||||
|-------------------|----------------------------------|
|
||||
| `pyproject.toml` | `requires-python = ">=3.11,<3.12"` |
|
||||
| `.python-version` | `3.11.8` |
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
uv run python --version
|
||||
```
|
||||
|
||||
> **Expected:** `Python 3.11.8`
|
||||
> Do **not** use Python 3.12+ unless compatibility has been fully verified and the project configuration intentionally updated.
|
||||
|
||||
---
|
||||
|
||||
## Installing uv
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
Close and reopen PowerShell, then verify:
|
||||
|
||||
```powershell
|
||||
uv --version
|
||||
```
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
Restart the terminal and verify:
|
||||
|
||||
```bash
|
||||
uv --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repository-url>
|
||||
cd docqube_backend
|
||||
|
||||
# 2. Install all dependencies
|
||||
uv sync
|
||||
```
|
||||
|
||||
`uv sync` will:
|
||||
|
||||
- Create `.venv` automatically
|
||||
- Use Python 3.11
|
||||
- Resolve dependencies from `pyproject.toml`
|
||||
- Install exact versions from `uv.lock`
|
||||
- Install Windows-specific packages when on Windows
|
||||
|
||||
> You do **not** need to manually create or activate a virtual environment.
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Run the application
|
||||
|
||||
```bash
|
||||
uv run python manage.py run --env local
|
||||
```
|
||||
|
||||
The application starts with the local environment configuration
|
||||
(example: `http://127.0.0.1:20001`).
|
||||
Host and port are controlled by the application settings.
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations & Seeding
|
||||
|
||||
All database migrations and seeds are executed through `uv`:
|
||||
|
||||
### 1. Apply Migrations (By Environment)
|
||||
|
||||
**Local Environment:**
|
||||
```bash
|
||||
uv run python manage.py migrate --env local
|
||||
```
|
||||
|
||||
**Test Environment:**
|
||||
```bash
|
||||
uv run python manage.py migrate --env test
|
||||
```
|
||||
|
||||
**Production Environment:**
|
||||
```bash
|
||||
uv run python manage.py migrate --env production
|
||||
```
|
||||
|
||||
*Or directly via Alembic (uses current environment variables):*
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### 2. Create a New Migration (Autogenerate from Models)
|
||||
|
||||
```bash
|
||||
uv run alembic revision --autogenerate -m "your_migration_name"
|
||||
```
|
||||
|
||||
### 3. Rollback the Last Migration
|
||||
|
||||
```bash
|
||||
uv run alembic downgrade -1
|
||||
```
|
||||
|
||||
### 4. Seed Database (By Environment)
|
||||
|
||||
**Local:**
|
||||
```bash
|
||||
uv run python manage.py seed --env local
|
||||
```
|
||||
|
||||
**Production:**
|
||||
```bash
|
||||
uv run python manage.py seed --env production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Management
|
||||
|
||||
All Python dependencies are managed exclusively through:
|
||||
|
||||
- `pyproject.toml`
|
||||
- `uv.lock`
|
||||
|
||||
> **Do not** maintain a separate `requirements.txt`.
|
||||
|
||||
### Add a production dependency
|
||||
|
||||
```bash
|
||||
uv add <package>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
uv add httpx
|
||||
uv add "httpx==0.28.1"
|
||||
uv add "pikepdf>=8.0.0,<11.0.0"
|
||||
```
|
||||
|
||||
### Add a development dependency
|
||||
|
||||
```bash
|
||||
uv add --dev pytest
|
||||
uv add --dev ruff
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Remove a dependency
|
||||
|
||||
```bash
|
||||
uv remove <package>
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Upgrade dependencies
|
||||
|
||||
**Single package:**
|
||||
|
||||
```bash
|
||||
uv lock --upgrade-package <package>
|
||||
uv sync
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
uv lock --upgrade-package fastapi
|
||||
uv sync
|
||||
```
|
||||
|
||||
**Specific version:**
|
||||
|
||||
```bash
|
||||
uv add "fastapi==0.116.1"
|
||||
```
|
||||
|
||||
**All packages** (use only for deliberate maintenance):
|
||||
|
||||
```bash
|
||||
uv lock --upgrade
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Lockfile
|
||||
|
||||
`uv.lock` **must** be committed to Git.
|
||||
It guarantees identical dependency resolution across developer machines, CI, Docker, staging, and production.
|
||||
|
||||
> **Never** edit `uv.lock` manually.
|
||||
|
||||
### Inspection commands
|
||||
|
||||
```bash
|
||||
# Full dependency tree
|
||||
uv tree
|
||||
|
||||
# Find a package (Windows PowerShell)
|
||||
uv tree | Select-String pikepdf
|
||||
|
||||
# Find a package (Linux / macOS)
|
||||
uv tree | grep pikepdf
|
||||
|
||||
# Inspect an installed package
|
||||
uv pip show pikepdf
|
||||
uv pip show torch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
### Development environment
|
||||
|
||||
```bash
|
||||
# Start (build + run)
|
||||
docker compose -p docqube-development -f docker-compose.development.yml up -d --build
|
||||
|
||||
# Follow logs
|
||||
docker compose -p docqube-development -f docker-compose.development.yml logs -f
|
||||
|
||||
# Stop
|
||||
docker compose -p docqube-development -f docker-compose.development.yml down
|
||||
|
||||
# Normal rebuild
|
||||
docker compose -p docqube-development -f docker-compose.development.yml up -d --build
|
||||
|
||||
# Completely fresh build (no cache)
|
||||
docker compose -p docqube-development -f docker-compose.development.yml build --no-cache
|
||||
docker compose -p docqube-development -f docker-compose.development.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Celery Worker
|
||||
|
||||
### Locally
|
||||
|
||||
```bash
|
||||
uv run celery -A app.tasks.celery_app:celery_app worker --loglevel=info
|
||||
```
|
||||
|
||||
> The worker requires the correct Redis and environment configuration.
|
||||
|
||||
### Recreate the virtual environment
|
||||
|
||||
If `.venv` becomes corrupted:
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
Remove-Item -Recurse -Force .venv
|
||||
uv sync
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
rm -rf .venv
|
||||
uv sync
|
||||
```
|
||||
|
||||
**Force reinstall**
|
||||
|
||||
```bash
|
||||
uv sync --reinstall
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Useful uv Commands
|
||||
|
||||
| Command | Description |
|
||||
|----------------------------------------------|-------------------------------------------|
|
||||
| `uv --version` | Check uv version |
|
||||
| `uv run python --version` | Check Python version |
|
||||
| `uv python find` | Locate the Python interpreter |
|
||||
| `uv sync` | Install / synchronize dependencies |
|
||||
| `uv add <package>` | Add a production dependency |
|
||||
| `uv add --dev <package>` | Add a development dependency |
|
||||
| `uv remove <package>` | Remove a dependency |
|
||||
| `uv lock` | Update the lockfile |
|
||||
| `uv lock --upgrade-package <pkg>` | Upgrade one package |
|
||||
| `uv lock --upgrade` | Upgrade all packages |
|
||||
| `uv tree` | View dependency tree |
|
||||
| `uv pip show <package>` | Inspect an installed package |
|
||||
| `uv run python` | Run Python inside the project environment |
|
||||
| `uv run python manage.py run --env local` | Run the application |
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### New developer quick start
|
||||
|
||||
1. Clone the repository
|
||||
2. Install `uv`
|
||||
3. Run `uv sync`
|
||||
4. Create the required environment file (e.g. `.env.development`)
|
||||
5. Verify Python → `uv run python --version` → `Python 3.11.8`
|
||||
6. Run locally **or** start the full stack with Docker Compose
|
||||
7. Check services and view logs
|
||||
|
||||
### Day-to-day workflow
|
||||
|
||||
```bash
|
||||
# Normal local development
|
||||
uv sync
|
||||
uv run python manage.py run --env local
|
||||
|
||||
# Add a package
|
||||
uv add <package>
|
||||
|
||||
# Remove a package
|
||||
uv remove <package>
|
||||
|
||||
# Upgrade one package
|
||||
uv lock --upgrade-package <package>
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Before creating a pull request
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run python --version
|
||||
uv run pytest
|
||||
docker compose -p docqube-development -f docker-compose.development.yml build
|
||||
docker compose -p docqube-development -f docker-compose.development.yml up -d
|
||||
```
|
||||
|
||||
### Important rules
|
||||
|
||||
**Do**
|
||||
|
||||
- Use `uv sync`, `uv add`, `uv remove`, `uv run`
|
||||
- Commit `pyproject.toml`, `uv.lock`, and `.python-version`
|
||||
- Use Docker Compose for the full containerized environment
|
||||
|
||||
**Do not**
|
||||
|
||||
- Use `pip install ...`
|
||||
- Manually create `env/` or `venv/`
|
||||
- Manually edit `uv.lock`
|
||||
- Commit `.venv/` or real `.env` files
|
||||
- Maintain a manually synchronized `requirements.txt`
|
||||
|
||||
---
|
||||
|
||||
## Source of Truth
|
||||
|
||||
| Concern | Source of Truth |
|
||||
|--------------------------------|----------------------------|
|
||||
| Python version | `.python-version` |
|
||||
| Python dependencies | `pyproject.toml` |
|
||||
| Locked dependencies | `uv.lock` |
|
||||
| Python / Docker environment | `Dockerfile` |
|
||||
| Environment orchestration | `docker-compose.*.yml` |
|
||||
| Environment configuration | `.env.*` |
|
||||
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql+psycopg2://dummy
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,43 @@
|
||||
import sys
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.core.settings import DATABASE_URL
|
||||
from app.db.database import Base
|
||||
|
||||
# Import all models here so Alembic can detect them.
|
||||
#
|
||||
# `app.db.all_models` is the one that matters. Importing `app.main` reaches
|
||||
# most models by accident — routes import what they use — but it missed
|
||||
# `user_roles`, `access_groups` and `user_access_groups`, whose every import is
|
||||
# function-local. Autogenerate reported all three as removed tables and offered
|
||||
# to drop the SBAC schema. See tests/probes/test_model_registry.py.
|
||||
import app.db.all_models # noqa: F401
|
||||
import app.main
|
||||
from app.modules.tenant.models.tenant_contact_model import TenantContact
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL.replace('%', '%%'))
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${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 identifiers, used by Alembic.
|
||||
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,26 @@
|
||||
"""add collaboration chat
|
||||
|
||||
Revision ID: 06001599321d
|
||||
Revises: 22dee9a1d631
|
||||
Create Date: 2026-02-26 16:13:54.998247
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '06001599321d'
|
||||
down_revision: Union[str, None] = '22dee9a1d631'
|
||||
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,35 @@
|
||||
"""disable_rls_temporarily
|
||||
|
||||
Revision ID: 0bffea34ee5f
|
||||
Revises: 14b4aa5f0f5d
|
||||
Create Date: 2026-09-05 12:01:30.634229
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0bffea34ee5f'
|
||||
down_revision: Union[str, None] = '14b4aa5f0f5d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
RLS_TABLES = [
|
||||
"user_org_units", "org_unit_leads", "user_roles", "access_groups", "user_access_groups",
|
||||
"org_units", "tenant_subscriptions", "tenant_limit_overrides", "chatbot_documents",
|
||||
"drive_activities", "drive_files", "drive_folders", "notifications", "tenant_smtp_configs",
|
||||
"user_files", "projects", "roles", "activity_logs", "tenant_storage_configs",
|
||||
"chat_sessions", "signing_requests", "tenant_signature_configs", "users"
|
||||
]
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in RLS_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in RLS_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add max_bytes_quota to user_storage_usage
|
||||
|
||||
Revision ID: 0e1a5f2cdea9
|
||||
Revises: 9a6db38b86a6
|
||||
Create Date: 2026-03-02 10:48:38.335359
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0e1a5f2cdea9'
|
||||
down_revision: Union[str, None] = '9a6db38b86a6'
|
||||
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.add_column('project_versions', sa.Column('drive_file_version_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'project_versions', 'drive_file_versions', ['drive_file_version_id'], ['id'])
|
||||
op.add_column('user_files', sa.Column('is_deleted', sa.Boolean(), nullable=True))
|
||||
op.add_column('user_files', sa.Column('deleted_at', sa.TIMESTAMP(), nullable=True))
|
||||
op.add_column('user_storage_usage', sa.Column('max_bytes_quota', sa.BigInteger(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_storage_usage', 'max_bytes_quota')
|
||||
op.drop_column('user_files', 'deleted_at')
|
||||
op.drop_column('user_files', 'is_deleted')
|
||||
op.drop_constraint(None, 'project_versions', type_='foreignkey')
|
||||
op.drop_column('project_versions', 'drive_file_version_id')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Merge multiple heads
|
||||
|
||||
Revision ID: 14b4aa5f0f5d
|
||||
Revises: e1_0_add_saas_plan_id, z005_add_virus_scan
|
||||
Create Date: 2026-09-02 19:15:56.213495
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '14b4aa5f0f5d'
|
||||
down_revision: Union[str, None] = ('e1_0_add_saas_plan_id', 'z005_add_virus_scan')
|
||||
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,66 @@
|
||||
"""add tenant storage settings
|
||||
|
||||
Revision ID: 15f935213b22
|
||||
Revises: k002_add_docs_tutorial
|
||||
Create Date: 2026-04-17 15:49:28.721223
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '15f935213b22'
|
||||
down_revision: Union[str, None] = 'k002_add_docs_tutorial'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _index_names(bind, table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(bind)
|
||||
return {idx["name"] for idx in inspector.get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table('tenant_storage_configs'):
|
||||
op.create_table('tenant_storage_configs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('b2_key_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('encrypted_b2_application_key', sa.String(length=512), nullable=False),
|
||||
sa.Column('b2_quarantine_bucket', sa.String(length=255), nullable=False),
|
||||
sa.Column('b2_clean_bucket', sa.String(length=255), nullable=False),
|
||||
sa.Column('b2_endpoint', sa.String(length=512), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
existing_indexes = _index_names(bind, 'tenant_storage_configs')
|
||||
if 'ix_tenant_storage_configs_tenant_id' not in existing_indexes:
|
||||
with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_tenant_storage_configs_tenant_id'), ['tenant_id'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if inspector.has_table('tenant_storage_configs'):
|
||||
existing_indexes = _index_names(bind, 'tenant_storage_configs')
|
||||
if 'ix_tenant_storage_configs_tenant_id' in existing_indexes:
|
||||
with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_tenant_storage_configs_tenant_id'))
|
||||
|
||||
op.drop_table('tenant_storage_configs')
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Add SaaS mapping tables
|
||||
|
||||
Revision ID: 1cb413ffaaa0
|
||||
Revises: n002_add_signature_imprints
|
||||
Create Date: 2026-04-23 23:16:32.782986
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1cb413ffaaa0'
|
||||
down_revision: Union[str, None] = 'n002_add_signature_imprints'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- SaaSTenantMapping ---
|
||||
op.create_table(
|
||||
'saas_tenant_mappings',
|
||||
sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('saas_tenant_id', sa.String(), nullable=False),
|
||||
sa.Column('docqube_tenant_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['docqube_tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_saas_tenant_mappings_id'), 'saas_tenant_mappings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_saas_tenant_mappings_saas_tenant_id'), 'saas_tenant_mappings', ['saas_tenant_id'], unique=True)
|
||||
|
||||
# --- SaaSUserMapping ---
|
||||
op.create_table(
|
||||
'saas_user_mappings',
|
||||
sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('saas_user_id', sa.String(), nullable=False),
|
||||
sa.Column('docqube_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['docqube_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_saas_user_mappings_id'), 'saas_user_mappings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_saas_user_mappings_saas_user_id'), 'saas_user_mappings', ['saas_user_id'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_saas_user_mappings_saas_user_id'), table_name='saas_user_mappings')
|
||||
op.drop_index(op.f('ix_saas_user_mappings_id'), table_name='saas_user_mappings')
|
||||
op.drop_table('saas_user_mappings')
|
||||
op.drop_index(op.f('ix_saas_tenant_mappings_saas_tenant_id'), table_name='saas_tenant_mappings')
|
||||
op.drop_index(op.f('ix_saas_tenant_mappings_id'), table_name='saas_tenant_mappings')
|
||||
op.drop_table('saas_tenant_mappings')
|
||||
@@ -0,0 +1,67 @@
|
||||
"""add missing vector columns
|
||||
|
||||
Revision ID: 1f4d2a8c9b77
|
||||
Revises: fb6810c36ffb
|
||||
Create Date: 2026-04-08 22:05:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1f4d2a8c9b77"
|
||||
down_revision: Union[str, None] = "fb6810c36ffb"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
return column_name in {col["name"] for col in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "vector_indices"):
|
||||
if not _column_exists(bind, "vector_indices", "index_name"):
|
||||
op.add_column(
|
||||
"vector_indices", sa.Column("index_name", sa.String(length=255), nullable=True)
|
||||
)
|
||||
if not _column_exists(bind, "vector_indices", "checksum"):
|
||||
op.add_column(
|
||||
"vector_indices", sa.Column("checksum", sa.String(length=255), nullable=True)
|
||||
)
|
||||
if not _column_exists(bind, "vector_indices", "metadata_info"):
|
||||
op.add_column(
|
||||
"vector_indices", sa.Column("metadata_info", sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
if _table_exists(bind, "vector_chunks"):
|
||||
if not _column_exists(bind, "vector_chunks", "embedding_json"):
|
||||
op.add_column(
|
||||
"vector_chunks", sa.Column("embedding_json", sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "vector_chunks"):
|
||||
if _column_exists(bind, "vector_chunks", "embedding_json"):
|
||||
op.drop_column("vector_chunks", "embedding_json")
|
||||
|
||||
if _table_exists(bind, "vector_indices"):
|
||||
if _column_exists(bind, "vector_indices", "metadata_info"):
|
||||
op.drop_column("vector_indices", "metadata_info")
|
||||
if _column_exists(bind, "vector_indices", "checksum"):
|
||||
op.drop_column("vector_indices", "checksum")
|
||||
if _column_exists(bind, "vector_indices", "index_name"):
|
||||
op.drop_column("vector_indices", "index_name")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""add_terms_accepted
|
||||
|
||||
Revision ID: 229359b0d1b2
|
||||
Revises: 823a486fd287
|
||||
Create Date: 2026-04-01 11:28:44.953518
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '229359b0d1b2'
|
||||
down_revision: Union[str, None] = '823a486fd287'
|
||||
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! ###
|
||||
with op.batch_alter_table('drive_comments', schema=None) as batch_op:
|
||||
batch_op.alter_column('rejected_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.DateTime(),
|
||||
existing_nullable=True)
|
||||
|
||||
with op.batch_alter_table('drive_stars', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('terms_accepted', sa.Boolean(), nullable=False, server_default=sa.text('false')))
|
||||
batch_op.drop_index(batch_op.f('ix_users_email'))
|
||||
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_users_email'))
|
||||
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
|
||||
batch_op.drop_column('terms_accepted')
|
||||
|
||||
with op.batch_alter_table('drive_stars', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=sa.TIMESTAMP(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
|
||||
with op.batch_alter_table('drive_comments', schema=None) as batch_op:
|
||||
batch_op.alter_column('rejected_at',
|
||||
existing_type=sa.DateTime(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
existing_nullable=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add shared_by_user_id to ProjectShare
|
||||
|
||||
Revision ID: 22dee9a1d631
|
||||
Revises: dbaa8ef522c0
|
||||
Create Date: 2026-02-26 15:33:00.617693
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '22dee9a1d631'
|
||||
down_revision: Union[str, None] = 'dbaa8ef522c0'
|
||||
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.add_column('project_shares', sa.Column('shared_by_user_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'project_shares', 'users', ['shared_by_user_id'], ['id'], ondelete='CASCADE')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'project_shares', type_='foreignkey')
|
||||
op.drop_column('project_shares', 'shared_by_user_id')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,258 @@
|
||||
"""initial migration
|
||||
|
||||
Revision ID: 29c02cb50737
|
||||
Revises:
|
||||
Create Date: 2026-02-16 19:28:02.116622
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '29c02cb50737'
|
||||
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('drive_acl',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=10), nullable=False),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=False),
|
||||
sa.Column('subject_type', sa.String(length=10), nullable=False),
|
||||
sa.Column('subject_id', sa.Integer(), nullable=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('inherited_from', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('subscription', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_table('drive_activities',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=10), nullable=False),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=False),
|
||||
sa.Column('actor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('activity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('activity_metadata', sa.Text(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=64), nullable=True),
|
||||
sa.Column('device_info', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['actor_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('drive_folders',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('color', sa.String(length=20), nullable=True),
|
||||
sa.Column('is_starred', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_trashed', sa.Boolean(), nullable=False),
|
||||
sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['drive_folders.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('drive_shares',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=10), nullable=False),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=False),
|
||||
sa.Column('invited_email', sa.String(length=255), nullable=True),
|
||||
sa.Column('link_token', sa.String(length=255), nullable=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('link_token')
|
||||
)
|
||||
op.create_table('drive_stars',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=10), nullable=False),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('resource_type', 'resource_id', 'user_id', name='uq_drive_star')
|
||||
)
|
||||
op.create_table('projects',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('file_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('output_format', sa.String(length=50), nullable=False),
|
||||
sa.Column('progress', sa.Integer(), nullable=False),
|
||||
sa.Column('current_step', sa.String(length=50), nullable=False),
|
||||
sa.Column('session_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('html_content', sa.Text(), nullable=True),
|
||||
sa.Column('markdown_content', sa.Text(), nullable=True),
|
||||
sa.Column('xml_content', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('user_files',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('file_name', sa.String(length=512), nullable=False),
|
||||
sa.Column('s3_key', sa.String(length=1024), nullable=False),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('mime_type', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_deleted', sa.Boolean(), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('s3_key')
|
||||
)
|
||||
op.create_table('user_storage_usage',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('total_bytes', sa.BigInteger(), nullable=False),
|
||||
sa.Column('file_count', sa.Integer(), nullable=False),
|
||||
sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('user_id')
|
||||
)
|
||||
op.create_table('drive_files',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('folder_id', sa.Integer(), nullable=False),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=512), nullable=False),
|
||||
sa.Column('mime_type', sa.String(length=255), nullable=True),
|
||||
sa.Column('size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('hash', sa.String(length=128), nullable=True),
|
||||
sa.Column('latest_version_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_starred', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_trashed', sa.Boolean(), nullable=False),
|
||||
sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['folder_id'], ['drive_folders.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('project_activities',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('activity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('activity_metadata', sa.Text(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=64), nullable=True),
|
||||
sa.Column('device_info', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('project_comments',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('project_shares',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('shared_with_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('shared_by_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('permission', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('project_id', 'shared_with_user_id', name='unique_project_user_share')
|
||||
)
|
||||
op.create_table('project_versions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('version_number', sa.Integer(), nullable=False),
|
||||
sa.Column('changes_description', sa.String(length=500), nullable=True),
|
||||
sa.Column('markdown_content', sa.Text(), nullable=True),
|
||||
sa.Column('xml_content', sa.Text(), nullable=True),
|
||||
sa.Column('html_content', sa.Text(), nullable=True),
|
||||
sa.Column('session_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_current', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('drive_comments',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('file_id', sa.Integer(), nullable=False),
|
||||
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['drive_files.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('drive_file_versions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('file_id', sa.Integer(), nullable=False),
|
||||
sa.Column('version_number', sa.Integer(), nullable=False),
|
||||
sa.Column('storage_key', sa.Text(), nullable=False),
|
||||
sa.Column('mime_type', sa.String(length=255), nullable=True),
|
||||
sa.Column('size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('checksum', sa.String(length=128), nullable=True),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['drive_files.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('drive_file_versions')
|
||||
op.drop_table('drive_comments')
|
||||
op.drop_table('project_versions')
|
||||
op.drop_table('project_shares')
|
||||
op.drop_table('project_comments')
|
||||
op.drop_table('project_activities')
|
||||
op.drop_table('drive_files')
|
||||
op.drop_table('user_storage_usage')
|
||||
op.drop_table('user_files')
|
||||
op.drop_table('projects')
|
||||
op.drop_table('drive_stars')
|
||||
op.drop_table('drive_shares')
|
||||
op.drop_table('drive_folders')
|
||||
op.drop_table('drive_activities')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('drive_acl')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Add subscription fields and PlanPriceHistory
|
||||
|
||||
Revision ID: 2b4c62c1cf35
|
||||
Revises: d2_0_credit_limit_precision
|
||||
Create Date: 2026-08-19 18:04:44.734002
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2b4c62c1cf35'
|
||||
down_revision: Union[str, None] = 'd2_0_credit_limit_precision'
|
||||
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('plan_price_history',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('plan_id', sa.UUID(), nullable=False),
|
||||
sa.Column('old_price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('new_price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('currency', sa.String(length=10), nullable=True),
|
||||
sa.Column('changed_by', sa.Integer(), nullable=True),
|
||||
sa.Column('changed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_plan_price_history_plan_id'), 'plan_price_history', ['plan_id'], unique=False)
|
||||
|
||||
op.add_column('plans', sa.Column('price_amount', sa.Numeric(precision=10, scale=2), nullable=True))
|
||||
op.add_column('plans', sa.Column('interval', sa.String(length=20), nullable=True))
|
||||
op.add_column('plans', sa.Column('grace_period_days', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('plans', sa.Column('notify_days_before_expiry', sa.Integer(), server_default='7', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('plans', 'notify_days_before_expiry')
|
||||
op.drop_column('plans', 'grace_period_days')
|
||||
op.drop_column('plans', 'interval')
|
||||
op.drop_column('plans', 'price_amount')
|
||||
op.drop_index(op.f('ix_plan_price_history_plan_id'), table_name='plan_price_history')
|
||||
op.drop_table('plan_price_history')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,128 @@
|
||||
"""add signature_id to signature_imprints
|
||||
|
||||
Revision ID: 39d5fdfb5386
|
||||
Revises: 1cb413ffaaa0
|
||||
Create Date: 2026-04-25 15:33:32.819817
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '39d5fdfb5386'
|
||||
down_revision: Union[str, None] = '1cb413ffaaa0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Legacy tables/indexes — drop only if present (some envs already lack them).
|
||||
op.execute("DROP TABLE IF EXISTS vector_chunks CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS vector_indices CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS chat_token_usage CASCADE")
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_chatbot_documents_drive_file_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_chatbot_documents_id")
|
||||
|
||||
with op.batch_alter_table('extraction_data', schema=None) as batch_op:
|
||||
batch_op.alter_column('confidence_score',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
nullable=False)
|
||||
batch_op.alter_column('extraction_status',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False)
|
||||
op.execute("DROP INDEX IF EXISTS idx_extraction_data_document_id")
|
||||
op.create_index(op.f('ix_extraction_data_document_id'), 'extraction_data', ['document_id'], unique=False)
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_signature_imprints_file_page")
|
||||
op.execute("DROP INDEX IF EXISTS ix_signature_imprints_signing_request")
|
||||
with op.batch_alter_table('signature_imprints', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('signature_id', sa.String(length=15), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_signature_imprints_signing_request_id'), ['signing_request_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('signature_imprints', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_signature_imprints_signing_request_id'))
|
||||
batch_op.create_index(batch_op.f('ix_signature_imprints_signing_request'), ['signing_request_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_signature_imprints_file_page'), ['drive_file_id', 'page_number'], unique=False)
|
||||
batch_op.drop_column('signature_id')
|
||||
|
||||
with op.batch_alter_table('extraction_data', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_extraction_data_document_id'))
|
||||
batch_op.create_index(batch_op.f('idx_extraction_data_document_id'), ['document_id'], unique=False)
|
||||
batch_op.alter_column('extraction_status',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True)
|
||||
batch_op.alter_column('confidence_score',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
nullable=True)
|
||||
|
||||
with op.batch_alter_table('chatbot_documents', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_chatbot_documents_id'), ['id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chatbot_documents_drive_file_id'), ['drive_file_id'], unique=False)
|
||||
|
||||
op.create_table('chat_token_usage',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('chat_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('document_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('prompt_tokens', sa.BIGINT(), autoincrement=False, nullable=False),
|
||||
sa.Column('completion_tokens', sa.BIGINT(), autoincrement=False, nullable=False),
|
||||
sa.Column('total_tokens', sa.BIGINT(), autoincrement=False, nullable=False),
|
||||
sa.Column('model_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('cost_usd', sa.NUMERIC(precision=12, scale=8), autoincrement=False, nullable=False),
|
||||
sa.Column('timestamp', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('phase', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('chat_token_usage_user_id_fkey'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('chat_token_usage_pkey'))
|
||||
)
|
||||
with op.batch_alter_table('chat_token_usage', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_user_time'), ['user_id', 'timestamp'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_timestamp'), ['timestamp'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_phase'), ['phase'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_model_time'), ['model_name', 'timestamp'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_model_name'), ['model_name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_id'), ['id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_document_id'), ['document_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_chat_id'), ['chat_id'], unique=False)
|
||||
|
||||
op.create_table('vector_indices',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('index_data', postgresql.BYTEA(), autoincrement=False, nullable=True),
|
||||
sa.Column('dimension', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('total_vectors', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.Column('index_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('checksum', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('metadata_info', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('vector_indices_user_id_fkey'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('vector_indices_pkey'))
|
||||
)
|
||||
with op.batch_alter_table('vector_indices', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_vector_indices_user_id'), ['user_id'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_vector_indices_id'), ['id'], unique=False)
|
||||
|
||||
op.create_table('vector_chunks',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('document_id', sa.VARCHAR(), autoincrement=False, nullable=False),
|
||||
sa.Column('chunk_text', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('chunk_index', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.Column('embedding_json', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('vector_chunks_user_id_fkey'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('vector_chunks_pkey'))
|
||||
)
|
||||
with op.batch_alter_table('vector_chunks', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_vector_chunks_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_vector_chunks_id'), ['id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_vector_chunks_document_id'), ['document_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Enforce enum constraint on DriveACL role
|
||||
|
||||
Revision ID: 3aa7d970b8c2
|
||||
Revises: 89508c176437
|
||||
Create Date: 2026-03-03 22:06:24.190823
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3aa7d970b8c2'
|
||||
down_revision: Union[str, None] = '89508c176437'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# The four valid drive roles
|
||||
VALID_ROLES = ('viewer', 'commenter', 'editor', 'owner')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create the PostgreSQL enum type (idempotent via IF NOT EXISTS)
|
||||
op.execute("CREATE TYPE drive_role AS ENUM ('viewer', 'commenter', 'editor', 'owner')")
|
||||
|
||||
# 2. Sanitise any NULL or invalid rows before adding NOT NULL constraint.
|
||||
# Default unknown/null values to 'viewer' (least-privileged), then log them.
|
||||
op.execute(
|
||||
"UPDATE drive_acls SET role = 'viewer' "
|
||||
"WHERE role IS NULL OR role NOT IN ('viewer', 'commenter', 'editor', 'owner')"
|
||||
)
|
||||
|
||||
# 3. Alter the column: cast existing VARCHAR values to the new enum type
|
||||
op.execute(
|
||||
"ALTER TABLE drive_acls "
|
||||
"ALTER COLUMN role TYPE drive_role USING role::drive_role, "
|
||||
"ALTER COLUMN role SET NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert column to plain VARCHAR(50), nullable
|
||||
op.execute(
|
||||
"ALTER TABLE drive_acls "
|
||||
"ALTER COLUMN role TYPE VARCHAR(50) USING role::VARCHAR, "
|
||||
"ALTER COLUMN role DROP NOT NULL"
|
||||
)
|
||||
# Drop the enum type (only if no other table references it)
|
||||
op.execute("DROP TYPE IF EXISTS drive_role")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Refactor documents and add missing fields
|
||||
|
||||
Revision ID: 41bed7eb6ed9
|
||||
Revises: 06001599321d
|
||||
Create Date: 2026-02-27 14:21:01.746676
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '41bed7eb6ed9'
|
||||
down_revision: Union[str, None] = '06001599321d'
|
||||
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.drop_table('project_activities')
|
||||
op.execute("DROP INDEX IF EXISTS ix_collaboration_chat_resource_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_collaboration_chat_resource_type")
|
||||
op.execute("DROP TABLE IF EXISTS collaboration_chat CASCADE")
|
||||
op.drop_table('project_comments')
|
||||
op.drop_table('project_shares')
|
||||
op.add_column('project_versions', sa.Column('changes_description', sa.Text(), nullable=True))
|
||||
op.add_column('project_versions', sa.Column('session_id', sa.String(length=255), nullable=True))
|
||||
op.drop_column('project_versions', 'description')
|
||||
op.add_column('projects', sa.Column('drive_file_id', sa.Integer(), nullable=True))
|
||||
op.create_index('idx_project_session', 'projects', ['session_id'], unique=False)
|
||||
op.create_index('idx_project_user_status', 'projects', ['user_id', 'status'], unique=False)
|
||||
op.create_index(op.f('ix_projects_drive_file_id'), 'projects', ['drive_file_id'], unique=False)
|
||||
op.create_foreign_key(None, 'projects', 'drive_files', ['drive_file_id'], ['id'], ondelete='CASCADE')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'projects', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_projects_drive_file_id'), table_name='projects')
|
||||
op.drop_index('idx_project_user_status', table_name='projects')
|
||||
op.drop_index('idx_project_session', table_name='projects')
|
||||
op.drop_column('projects', 'drive_file_id')
|
||||
op.add_column('project_versions', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True))
|
||||
op.drop_column('project_versions', 'session_id')
|
||||
op.drop_column('project_versions', 'changes_description')
|
||||
op.create_table('project_shares',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('shared_with_user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('permission', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('shared_by_user_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_shares_project_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], name='project_shares_shared_by_user_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], name='project_shares_shared_with_user_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='project_shares_pkey')
|
||||
)
|
||||
op.create_table('project_comments',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||
sa.Column('anchor', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['users.id'], name='project_comments_author_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_comments_project_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='project_comments_pkey')
|
||||
)
|
||||
op.create_table('collaboration_chat',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('resource_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('resource_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('message', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='collaboration_chat_user_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='collaboration_chat_pkey')
|
||||
)
|
||||
op.create_index('ix_collaboration_chat_resource_type', 'collaboration_chat', ['resource_type'], unique=False)
|
||||
op.create_index('ix_collaboration_chat_resource_id', 'collaboration_chat', ['resource_id'], unique=False)
|
||||
op.create_table('project_activities',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('activity_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('activity_metadata', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
|
||||
sa.Column('device_info', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_activities_project_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='project_activities_user_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='project_activities_pkey')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Limit Notification message to 2000 chars
|
||||
|
||||
Revision ID: 45c94ee15294
|
||||
Revises: d2e2c55290ee
|
||||
Create Date: 2026-03-03 22:24:55.795394
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '45c94ee15294'
|
||||
down_revision: Union[str, None] = 'd2e2c55290ee'
|
||||
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.alter_column('notifications', 'message',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sa.String(length=2000),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('notifications', 'message',
|
||||
existing_type=sa.String(length=2000),
|
||||
type_=sa.TEXT(),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,371 @@
|
||||
"""sync_models
|
||||
|
||||
Revision ID: 49c19df2c1d4
|
||||
Revises: 29c02cb50737
|
||||
Create Date: 2026-02-19 11:21:56.538569
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '49c19df2c1d4'
|
||||
down_revision: Union[str, None] = '29c02cb50737'
|
||||
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('drive_acls',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=False),
|
||||
sa.Column('subject_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role', sa.String(length=50), nullable=True),
|
||||
sa.Column('actor_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('chatbot_documents',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('document_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('drive_file_id', sa.Integer(), nullable=True),
|
||||
sa.Column('filename', sa.String(), nullable=False),
|
||||
sa.Column('file_path', sa.String(), nullable=True),
|
||||
sa.Column('cloud_url', sa.String(), nullable=True),
|
||||
sa.Column('chunks_count', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chatbot_documents_document_id'), 'chatbot_documents', ['document_id'], unique=True)
|
||||
op.create_index(op.f('ix_chatbot_documents_drive_file_id'), 'chatbot_documents', ['drive_file_id'], unique=False)
|
||||
op.create_index(op.f('ix_chatbot_documents_id'), 'chatbot_documents', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_chatbot_documents_user_id'), 'chatbot_documents', ['user_id'], unique=False)
|
||||
op.create_table('vector_chunks',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('document_id', sa.String(), nullable=True),
|
||||
sa.Column('chunk_text', sa.String(), nullable=False),
|
||||
sa.Column('chunk_index', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_vector_chunks_document_id'), 'vector_chunks', ['document_id'], unique=False)
|
||||
op.create_index(op.f('ix_vector_chunks_id'), 'vector_chunks', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_vector_chunks_user_id'), 'vector_chunks', ['user_id'], unique=False)
|
||||
op.create_table('vector_indices',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('index_data', sa.LargeBinary(), nullable=True),
|
||||
sa.Column('dimension', sa.Integer(), nullable=True),
|
||||
sa.Column('total_vectors', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_vector_indices_id'), 'vector_indices', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_vector_indices_user_id'), 'vector_indices', ['user_id'], unique=True)
|
||||
op.drop_table('drive_acl')
|
||||
op.alter_column('drive_activities', 'resource_type',
|
||||
existing_type=sa.VARCHAR(length=10),
|
||||
type_=sa.String(length=20),
|
||||
nullable=True)
|
||||
op.alter_column('drive_activities', 'resource_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.alter_column('drive_activities', 'actor_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.alter_column('drive_activities', 'activity_type',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True)
|
||||
op.alter_column('drive_activities', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_constraint(op.f('drive_activities_actor_id_fkey'), 'drive_activities', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_activities', 'users', ['actor_id'], ['id'])
|
||||
op.drop_column('drive_activities', 'device_info')
|
||||
op.drop_column('drive_activities', 'ip_address')
|
||||
op.add_column('drive_comments', sa.Column('user_id', sa.Integer(), nullable=False))
|
||||
op.add_column('drive_comments', sa.Column('content', sa.Text(), nullable=False))
|
||||
op.alter_column('drive_comments', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_constraint(op.f('drive_comments_file_id_fkey'), 'drive_comments', type_='foreignkey')
|
||||
op.drop_constraint(op.f('drive_comments_author_id_fkey'), 'drive_comments', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_comments', 'users', ['user_id'], ['id'])
|
||||
op.create_foreign_key(None, 'drive_comments', 'drive_files', ['file_id'], ['id'])
|
||||
op.drop_column('drive_comments', 'anchor')
|
||||
op.drop_column('drive_comments', 'body')
|
||||
op.drop_column('drive_comments', 'resolved_at')
|
||||
op.drop_column('drive_comments', 'author_id')
|
||||
op.add_column('drive_file_versions', sa.Column('s3_key', sa.String(), nullable=True))
|
||||
op.add_column('drive_file_versions', sa.Column('content_type', sa.String(), nullable=True))
|
||||
op.add_column('drive_file_versions', sa.Column('uploader_id', sa.Integer(), nullable=True))
|
||||
op.alter_column('drive_file_versions', 'file_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.alter_column('drive_file_versions', 'version_number',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.alter_column('drive_file_versions', 'size',
|
||||
existing_type=sa.BIGINT(),
|
||||
nullable=True)
|
||||
op.alter_column('drive_file_versions', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_constraint(op.f('drive_file_versions_file_id_fkey'), 'drive_file_versions', type_='foreignkey')
|
||||
op.drop_constraint(op.f('drive_file_versions_created_by_id_fkey'), 'drive_file_versions', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_file_versions', 'drive_files', ['file_id'], ['id'])
|
||||
op.create_foreign_key(None, 'drive_file_versions', 'users', ['uploader_id'], ['id'])
|
||||
op.drop_column('drive_file_versions', 'checksum')
|
||||
op.drop_column('drive_file_versions', 'created_by_id')
|
||||
op.drop_column('drive_file_versions', 'storage_key')
|
||||
op.drop_column('drive_file_versions', 'mime_type')
|
||||
op.alter_column('drive_stars', 'user_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.alter_column('drive_stars', 'resource_type',
|
||||
existing_type=sa.VARCHAR(length=10),
|
||||
nullable=True)
|
||||
op.alter_column('drive_stars', 'resource_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.drop_constraint(op.f('uq_drive_star'), 'drive_stars', type_='unique')
|
||||
op.drop_constraint(op.f('drive_stars_user_id_fkey'), 'drive_stars', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_stars', 'users', ['user_id'], ['id'])
|
||||
op.drop_column('drive_stars', 'created_at')
|
||||
op.alter_column('project_activities', 'ip_address',
|
||||
existing_type=sa.VARCHAR(length=64),
|
||||
type_=sa.String(length=45),
|
||||
existing_nullable=True)
|
||||
op.alter_column('project_comments', 'anchor',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True)
|
||||
op.alter_column('project_shares', 'permission',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
type_=sa.String(length=20),
|
||||
existing_nullable=False)
|
||||
op.drop_constraint(op.f('unique_project_user_share'), 'project_shares', type_='unique')
|
||||
op.drop_constraint(op.f('project_shares_shared_by_user_id_fkey'), 'project_shares', type_='foreignkey')
|
||||
op.drop_column('project_shares', 'updated_at')
|
||||
op.drop_column('project_shares', 'shared_by_user_id')
|
||||
op.add_column('project_versions', sa.Column('version_label', sa.String(length=50), nullable=False))
|
||||
op.add_column('project_versions', sa.Column('description', sa.Text(), nullable=True))
|
||||
op.alter_column('project_versions', 'created_by_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.drop_constraint(op.f('project_versions_created_by_id_fkey'), 'project_versions', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'project_versions', 'users', ['created_by_id'], ['id'], ondelete='SET NULL')
|
||||
op.drop_column('project_versions', 'changes_description')
|
||||
op.drop_column('project_versions', 'session_id')
|
||||
op.add_column('user_files', sa.Column('filename', sa.String(), nullable=False))
|
||||
op.add_column('user_files', sa.Column('size', sa.BigInteger(), nullable=True))
|
||||
op.add_column('user_files', sa.Column('content_type', sa.String(), nullable=True))
|
||||
op.add_column('user_files', sa.Column('status', sa.String(), nullable=True))
|
||||
op.alter_column('user_files', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_constraint(op.f('user_files_s3_key_key'), 'user_files', type_='unique')
|
||||
op.create_index(op.f('ix_user_files_id'), 'user_files', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_user_files_user_id'), 'user_files', ['user_id'], unique=False)
|
||||
op.drop_constraint(op.f('user_files_user_id_fkey'), 'user_files', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'user_files', 'users', ['user_id'], ['id'])
|
||||
op.drop_column('user_files', 'file_name')
|
||||
op.drop_column('user_files', 'deleted_at')
|
||||
op.drop_column('user_files', 'file_size')
|
||||
op.drop_column('user_files', 'is_deleted')
|
||||
op.drop_column('user_files', 'mime_type')
|
||||
op.add_column('user_storage_usage', sa.Column('id', sa.Integer(), nullable=False))
|
||||
op.add_column('user_storage_usage', sa.Column('total_bytes_used', sa.BigInteger(), nullable=True))
|
||||
op.add_column('user_storage_usage', sa.Column('total_files_count', sa.Integer(), nullable=True))
|
||||
op.alter_column('user_storage_usage', 'last_updated',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.TIMESTAMP(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.create_index(op.f('ix_user_storage_usage_id'), 'user_storage_usage', ['id'], unique=False)
|
||||
op.create_unique_constraint(None, 'user_storage_usage', ['user_id'])
|
||||
op.drop_constraint(op.f('user_storage_usage_user_id_fkey'), 'user_storage_usage', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'user_storage_usage', 'users', ['user_id'], ['id'])
|
||||
op.drop_column('user_storage_usage', 'total_bytes')
|
||||
op.drop_column('user_storage_usage', 'file_count')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user_storage_usage', sa.Column('file_count', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||
op.add_column('user_storage_usage', sa.Column('total_bytes', sa.BIGINT(), autoincrement=False, nullable=False))
|
||||
op.drop_constraint(None, 'user_storage_usage', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('user_storage_usage_user_id_fkey'), 'user_storage_usage', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_constraint(None, 'user_storage_usage', type_='unique')
|
||||
op.drop_index(op.f('ix_user_storage_usage_id'), table_name='user_storage_usage')
|
||||
op.alter_column('user_storage_usage', 'last_updated',
|
||||
existing_type=sa.TIMESTAMP(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_column('user_storage_usage', 'total_files_count')
|
||||
op.drop_column('user_storage_usage', 'total_bytes_used')
|
||||
op.drop_column('user_storage_usage', 'id')
|
||||
op.add_column('user_files', sa.Column('mime_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||
op.add_column('user_files', sa.Column('is_deleted', sa.BOOLEAN(), autoincrement=False, nullable=False))
|
||||
op.add_column('user_files', sa.Column('file_size', sa.BIGINT(), autoincrement=False, nullable=False))
|
||||
op.add_column('user_files', sa.Column('deleted_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
|
||||
op.add_column('user_files', sa.Column('file_name', sa.VARCHAR(length=512), autoincrement=False, nullable=False))
|
||||
op.drop_constraint(None, 'user_files', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('user_files_user_id_fkey'), 'user_files', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_index(op.f('ix_user_files_user_id'), table_name='user_files')
|
||||
op.drop_index(op.f('ix_user_files_id'), table_name='user_files')
|
||||
op.create_unique_constraint(op.f('user_files_s3_key_key'), 'user_files', ['s3_key'])
|
||||
op.alter_column('user_files', 'created_at',
|
||||
existing_type=sa.TIMESTAMP(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_column('user_files', 'status')
|
||||
op.drop_column('user_files', 'content_type')
|
||||
op.drop_column('user_files', 'size')
|
||||
op.drop_column('user_files', 'filename')
|
||||
op.add_column('project_versions', sa.Column('session_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||
op.add_column('project_versions', sa.Column('changes_description', sa.VARCHAR(length=500), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'project_versions', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('project_versions_created_by_id_fkey'), 'project_versions', 'users', ['created_by_id'], ['id'])
|
||||
op.alter_column('project_versions', 'created_by_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.drop_column('project_versions', 'description')
|
||||
op.drop_column('project_versions', 'version_label')
|
||||
op.add_column('project_shares', sa.Column('shared_by_user_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||
op.add_column('project_shares', sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False))
|
||||
op.create_foreign_key(op.f('project_shares_shared_by_user_id_fkey'), 'project_shares', 'users', ['shared_by_user_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_unique_constraint(op.f('unique_project_user_share'), 'project_shares', ['project_id', 'shared_with_user_id'])
|
||||
op.alter_column('project_shares', 'permission',
|
||||
existing_type=sa.String(length=20),
|
||||
type_=sa.VARCHAR(length=50),
|
||||
existing_nullable=False)
|
||||
op.alter_column('project_comments', 'anchor',
|
||||
existing_type=sa.Text(),
|
||||
type_=postgresql.JSONB(astext_type=sa.Text()),
|
||||
existing_nullable=True)
|
||||
op.alter_column('project_activities', 'ip_address',
|
||||
existing_type=sa.String(length=45),
|
||||
type_=sa.VARCHAR(length=64),
|
||||
existing_nullable=True)
|
||||
op.add_column('drive_stars', sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False))
|
||||
op.drop_constraint(None, 'drive_stars', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('drive_stars_user_id_fkey'), 'drive_stars', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_unique_constraint(op.f('uq_drive_star'), 'drive_stars', ['resource_type', 'resource_id', 'user_id'])
|
||||
op.alter_column('drive_stars', 'resource_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.alter_column('drive_stars', 'resource_type',
|
||||
existing_type=sa.VARCHAR(length=10),
|
||||
nullable=False)
|
||||
op.alter_column('drive_stars', 'user_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.add_column('drive_file_versions', sa.Column('mime_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||
op.add_column('drive_file_versions', sa.Column('storage_key', sa.TEXT(), autoincrement=False, nullable=False))
|
||||
op.add_column('drive_file_versions', sa.Column('created_by_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||
op.add_column('drive_file_versions', sa.Column('checksum', sa.VARCHAR(length=128), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'drive_file_versions', type_='foreignkey')
|
||||
op.drop_constraint(None, 'drive_file_versions', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('drive_file_versions_created_by_id_fkey'), 'drive_file_versions', 'users', ['created_by_id'], ['id'])
|
||||
op.create_foreign_key(op.f('drive_file_versions_file_id_fkey'), 'drive_file_versions', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE')
|
||||
op.alter_column('drive_file_versions', 'created_at',
|
||||
existing_type=sa.TIMESTAMP(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('drive_file_versions', 'size',
|
||||
existing_type=sa.BIGINT(),
|
||||
nullable=False)
|
||||
op.alter_column('drive_file_versions', 'version_number',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.alter_column('drive_file_versions', 'file_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.drop_column('drive_file_versions', 'uploader_id')
|
||||
op.drop_column('drive_file_versions', 'content_type')
|
||||
op.drop_column('drive_file_versions', 's3_key')
|
||||
op.add_column('drive_comments', sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||
op.add_column('drive_comments', sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
|
||||
op.add_column('drive_comments', sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False))
|
||||
op.add_column('drive_comments', sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'drive_comments', type_='foreignkey')
|
||||
op.drop_constraint(None, 'drive_comments', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('drive_comments_author_id_fkey'), 'drive_comments', 'users', ['author_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key(op.f('drive_comments_file_id_fkey'), 'drive_comments', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE')
|
||||
op.alter_column('drive_comments', 'created_at',
|
||||
existing_type=sa.TIMESTAMP(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_column('drive_comments', 'content')
|
||||
op.drop_column('drive_comments', 'user_id')
|
||||
op.add_column('drive_activities', sa.Column('ip_address', sa.VARCHAR(length=64), autoincrement=False, nullable=True))
|
||||
op.add_column('drive_activities', sa.Column('device_info', sa.TEXT(), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'drive_activities', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('drive_activities_actor_id_fkey'), 'drive_activities', 'users', ['actor_id'], ['id'], ondelete='CASCADE')
|
||||
op.alter_column('drive_activities', 'created_at',
|
||||
existing_type=sa.TIMESTAMP(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('drive_activities', 'activity_type',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False)
|
||||
op.alter_column('drive_activities', 'actor_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.alter_column('drive_activities', 'resource_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.alter_column('drive_activities', 'resource_type',
|
||||
existing_type=sa.String(length=20),
|
||||
type_=sa.VARCHAR(length=10),
|
||||
nullable=False)
|
||||
op.create_table('drive_acl',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('resource_type', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||
sa.Column('resource_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('subject_type', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||
sa.Column('subject_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('role', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('inherited_from', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('drive_acl_pkey'))
|
||||
)
|
||||
op.drop_index(op.f('ix_vector_indices_user_id'), table_name='vector_indices')
|
||||
op.drop_index(op.f('ix_vector_indices_id'), table_name='vector_indices')
|
||||
op.drop_table('vector_indices')
|
||||
op.drop_index(op.f('ix_vector_chunks_user_id'), table_name='vector_chunks')
|
||||
op.drop_index(op.f('ix_vector_chunks_id'), table_name='vector_chunks')
|
||||
op.drop_index(op.f('ix_vector_chunks_document_id'), table_name='vector_chunks')
|
||||
op.drop_table('vector_chunks')
|
||||
op.drop_index(op.f('ix_chatbot_documents_user_id'), table_name='chatbot_documents')
|
||||
op.drop_index(op.f('ix_chatbot_documents_id'), table_name='chatbot_documents')
|
||||
op.drop_index(op.f('ix_chatbot_documents_drive_file_id'), table_name='chatbot_documents')
|
||||
op.drop_index(op.f('ix_chatbot_documents_document_id'), table_name='chatbot_documents')
|
||||
op.drop_table('chatbot_documents')
|
||||
op.drop_table('drive_acls')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,33 @@
|
||||
"""drop_price_display
|
||||
|
||||
Revision ID: 4a9dc50ce4af
|
||||
Revises: 2b4c62c1cf35
|
||||
Create Date: 2026-08-20 11:40:33.072200
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4a9dc50ce4af'
|
||||
down_revision: Union[str, None] = '2b4c62c1cf35'
|
||||
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.execute("UPDATE plans SET price_amount = CAST(SUBSTRING(price_display FROM '[\d.]+') AS NUMERIC) WHERE price_display IS NOT NULL AND price_display != 'Contact Us' AND price_amount IS NULL")
|
||||
op.drop_column('plans', 'price_display')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('plans', sa.Column('price_display', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
|
||||
op.execute("UPDATE plans SET price_display = '$' || price_amount WHERE price_amount IS NOT NULL")
|
||||
op.execute("UPDATE plans SET price_display = 'Contact Us' WHERE price_amount IS NULL")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add_drive_share_link_fields
|
||||
|
||||
Revision ID: 522ee6d91431
|
||||
Revises: 8d4dced54966
|
||||
Create Date: 2026-02-24 14:35:17.542820
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '522ee6d91431'
|
||||
down_revision: Union[str, None] = '8d4dced54966'
|
||||
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.add_column('drive_shares', sa.Column('is_active', sa.Boolean(), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('drive_shares', 'is_active')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add_tenant_chat_token_quota
|
||||
|
||||
Revision ID: 540c970ff890
|
||||
Revises: 7fc8dd61bb95
|
||||
Create Date: 2026-05-13 11:21:14.192033
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '540c970ff890'
|
||||
down_revision: Union[str, None] = '7fc8dd61bb95'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Get current connection and inspector
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Check if columns already exist (defensive migration)
|
||||
columns = [c['name'] for c in inspector.get_columns('tenants')]
|
||||
|
||||
if 'chat_token_daily_limit' not in columns:
|
||||
op.add_column('tenants', sa.Column('chat_token_daily_limit', sa.BigInteger(), nullable=False, server_default='1000000'))
|
||||
|
||||
if 'chat_tokens_used_today' not in columns:
|
||||
op.add_column('tenants', sa.Column('chat_tokens_used_today', sa.BigInteger(), nullable=False, server_default='0'))
|
||||
|
||||
if 'chat_tokens_last_reset_at' not in columns:
|
||||
op.add_column('tenants', sa.Column('chat_tokens_last_reset_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tenants', 'chat_tokens_last_reset_at')
|
||||
op.drop_column('tenants', 'chat_tokens_used_today')
|
||||
op.drop_column('tenants', 'chat_token_daily_limit')
|
||||
@@ -0,0 +1,74 @@
|
||||
"""increase_file_size_constraints
|
||||
|
||||
Revision ID: 56d090047e85
|
||||
Revises: j002_unlimited_tenant_quota
|
||||
Create Date: 2026-03-26 15:14:47.276886
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '56d090047e85'
|
||||
down_revision: Union[str, None] = 'j002_unlimited_tenant_quota'
|
||||
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! ###
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
# Drop old 10MB constraints
|
||||
batch_op.drop_constraint('ck_project_markdown_max', type_='check')
|
||||
batch_op.drop_constraint('ck_project_xml_max', type_='check')
|
||||
batch_op.drop_constraint('ck_project_html_max', type_='check')
|
||||
|
||||
# Add new 30MB constraints
|
||||
batch_op.create_check_constraint('ck_project_markdown_max', 'octet_length(markdown_content) <= 31457280')
|
||||
batch_op.create_check_constraint('ck_project_xml_max', 'octet_length(xml_content) <= 31457280')
|
||||
batch_op.create_check_constraint('ck_project_html_max', 'octet_length(html_content) <= 31457280')
|
||||
|
||||
|
||||
|
||||
with op.batch_alter_table('project_versions', schema=None) as batch_op:
|
||||
# Drop old 10MB constraints
|
||||
batch_op.drop_constraint('ck_version_markdown_max', type_='check')
|
||||
batch_op.drop_constraint('ck_version_xml_max', type_='check')
|
||||
batch_op.drop_constraint('ck_version_html_max', type_='check')
|
||||
|
||||
# Add new 30MB constraints
|
||||
batch_op.create_check_constraint('ck_version_markdown_max', 'octet_length(markdown_content) <= 31457280')
|
||||
batch_op.create_check_constraint('ck_version_xml_max', 'octet_length(xml_content) <= 31457280')
|
||||
batch_op.create_check_constraint('ck_version_html_max', 'octet_length(html_content) <= 31457280')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('project_versions', schema=None) as batch_op:
|
||||
# Drop new 30MB constraints
|
||||
batch_op.drop_constraint('ck_version_markdown_max', type_='check')
|
||||
batch_op.drop_constraint('ck_version_xml_max', type_='check')
|
||||
batch_op.drop_constraint('ck_version_html_max', type_='check')
|
||||
|
||||
# Re-add old 10MB constraints
|
||||
batch_op.create_check_constraint('ck_version_markdown_max', 'octet_length(markdown_content) <= 10485760')
|
||||
batch_op.create_check_constraint('ck_version_xml_max', 'octet_length(xml_content) <= 10485760')
|
||||
batch_op.create_check_constraint('ck_version_html_max', 'octet_length(html_content) <= 10485760')
|
||||
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
# Drop new 30MB constraints
|
||||
batch_op.drop_constraint('ck_project_markdown_max', type_='check')
|
||||
batch_op.drop_constraint('ck_project_xml_max', type_='check')
|
||||
batch_op.drop_constraint('ck_project_html_max', type_='check')
|
||||
|
||||
# Re-add old 10MB constraints
|
||||
batch_op.create_check_constraint('ck_project_markdown_max', 'octet_length(markdown_content) <= 10485760')
|
||||
batch_op.create_check_constraint('ck_project_xml_max', 'octet_length(xml_content) <= 10485760')
|
||||
batch_op.create_check_constraint('ck_project_html_max', 'octet_length(html_content) <= 10485760')
|
||||
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add_multiple_files_to_signing_request
|
||||
|
||||
Revision ID: 611d6b99ad11
|
||||
Revises: d551c03861f4
|
||||
Create Date: 2026-07-14 18:00:05.273738
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '611d6b99ad11'
|
||||
down_revision: Union[str, None] = 'd551c03861f4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('signing_requests', sa.Column('drive_file_ids', sa.JSON(), nullable=True))
|
||||
op.add_column('signing_requests', sa.Column('signed_file_ids', sa.JSON(), nullable=True))
|
||||
op.alter_column('signing_requests', 'drive_file_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column('signing_requests', 'drive_file_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.drop_column('signing_requests', 'signed_file_ids')
|
||||
op.drop_column('signing_requests', 'drive_file_ids')
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge_multiple_heads
|
||||
|
||||
Revision ID: 6c9cc3567866
|
||||
Revises: 45c94ee15294, ff9052a97835
|
||||
Create Date: 2026-03-05 07:53:09.436096
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6c9cc3567866'
|
||||
down_revision: Union[str, None] = ('45c94ee15294', 'ff9052a97835')
|
||||
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,43 @@
|
||||
"""Add tenant contacts
|
||||
|
||||
Revision ID: 73dff81eed72
|
||||
Revises: dfae38e4ede3
|
||||
Create Date: 2026-08-12 10:56:21.668158
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '73dff81eed72'
|
||||
down_revision: Union[str, None] = 'dfae38e4ede3'
|
||||
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('tenant_contacts',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id', 'email', name='uq_tenant_contacts_tenant_id_email'),
|
||||
)
|
||||
op.create_index(op.f('ix_tenant_contacts_email'), 'tenant_contacts', ['email'], unique=False)
|
||||
op.create_index(op.f('ix_tenant_contacts_tenant_id'), 'tenant_contacts', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_tenant_contacts_tenant_id'), table_name='tenant_contacts')
|
||||
op.drop_index(op.f('ix_tenant_contacts_email'), table_name='tenant_contacts')
|
||||
op.drop_table('tenant_contacts')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add_vector_index_checksum
|
||||
|
||||
Revision ID: 765b053e3dcd
|
||||
Revises: be67927ab00d
|
||||
Create Date: 2026-03-03 20:25:42.849543
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '765b053e3dcd'
|
||||
down_revision: Union[str, None] = 'be67927ab00d'
|
||||
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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""refactor_global_signature_config
|
||||
|
||||
Revision ID: 7d7641cc1bb2
|
||||
Revises: b3a7c1e9f042
|
||||
Create Date: 2026-07-21 07:19:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7d7641cc1bb2'
|
||||
down_revision: Union[str, None] = 'af57c3d9f1c2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('global_signature_configs', sa.Column('global_credentials_json', postgresql.JSON(astext_type=sa.Text()), nullable=True))
|
||||
op.add_column('global_signature_configs', sa.Column('enabled_providers', postgresql.JSON(astext_type=sa.Text()), server_default='["docuseal", "zoho_sign"]', nullable=False))
|
||||
op.drop_column('global_signature_configs', 'docuseal_credentials')
|
||||
op.drop_column('global_signature_configs', 'zoho_credentials')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column('global_signature_configs', sa.Column('zoho_credentials', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
|
||||
op.add_column('global_signature_configs', sa.Column('docuseal_credentials', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
|
||||
op.drop_column('global_signature_configs', 'enabled_providers')
|
||||
op.drop_column('global_signature_configs', 'global_credentials_json')
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Add chat token usage and quota tables
|
||||
|
||||
Revision ID: 7fc8dd61bb95
|
||||
Revises: z002_pref_lang
|
||||
Create Date: 2026-05-12 18:38:23.935092
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7fc8dd61bb95'
|
||||
down_revision: Union[str, None] = 'z002_pref_lang'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Get current connection and inspector
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# 1. Create chat_token_usage if missing
|
||||
if not inspector.has_table('chat_token_usage'):
|
||||
op.create_table('chat_token_usage',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
sa.Column('prompt_tokens', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('completion_tokens', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('total_tokens', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('model_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('cost_usd', sa.DECIMAL(precision=12, scale=8), nullable=False, server_default='0.0'),
|
||||
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('phase', sa.String(length=50), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('chat_token_usage', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_id'), ['id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_timestamp'), ['timestamp'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_token_usage_session_id'), ['session_id'], unique=False)
|
||||
|
||||
# 2. Create chat_token_quota if missing
|
||||
if not inspector.has_table('chat_token_quota'):
|
||||
op.create_table('chat_token_quota',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('daily_limit', sa.BigInteger(), nullable=False),
|
||||
sa.Column('tokens_used_today', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('last_reset_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('total_tokens_ever', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('user_id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if inspector.has_table('chat_token_quota'):
|
||||
op.drop_table('chat_token_quota')
|
||||
|
||||
if inspector.has_table('chat_token_usage'):
|
||||
op.drop_table('chat_token_usage')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add_is_popular_to_plans
|
||||
|
||||
Revision ID: 80be2c61e7fc
|
||||
Revises: 4a9dc50ce4af
|
||||
Create Date: 2026-08-20 12:47:53.790237
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '80be2c61e7fc'
|
||||
down_revision: Union[str, None] = '4a9dc50ce4af'
|
||||
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.add_column('plans', sa.Column('is_popular', sa.Boolean(), server_default=sa.text('false'), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('plans', 'is_popular')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add created_at to drive_stars
|
||||
|
||||
Revision ID: 823a486fd287
|
||||
Revises: 88717d544229
|
||||
Create Date: 2026-03-27 23:24:47.417678
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '823a486fd287'
|
||||
down_revision: Union[str, None] = '88717d544229'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add column as nullable first
|
||||
op.add_column('drive_stars', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# Update existing stars to current time so they aren't null
|
||||
op.execute("UPDATE drive_stars SET created_at = NOW() WHERE created_at IS NULL")
|
||||
|
||||
# Make column non-nullable with server default
|
||||
op.alter_column('drive_stars', 'created_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('now()'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('drive_stars', 'created_at')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add_notifications_table
|
||||
|
||||
Revision ID: 8736ab6bd5af
|
||||
Revises: 522ee6d91431
|
||||
Create Date: 2026-02-24 15:25:34.045912
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8736ab6bd5af'
|
||||
down_revision: Union[str, None] = '522ee6d91431'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'notifications',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('notif_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=20), nullable=True),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_notifications_user_id', 'notifications', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_notifications_user_id', table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add is_default to roles
|
||||
|
||||
Revision ID: 88717d544229
|
||||
Revises: 56d090047e85
|
||||
Create Date: 2026-03-27 14:08:16.581384
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '88717d544229'
|
||||
down_revision: Union[str, None] = '56d090047e85'
|
||||
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! ###
|
||||
with op.batch_alter_table('roles', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('false')))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('roles', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_default')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Limit DriveComment body to 10000 chars
|
||||
|
||||
Revision ID: 89508c176437
|
||||
Revises: ff8052a97834
|
||||
Create Date: 2026-03-03 22:02:03.251490
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '89508c176437'
|
||||
down_revision: Union[str, None] = 'ff8052a97834'
|
||||
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.alter_column('drive_comments', 'body',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sa.String(length=10000),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('drive_comments', 'body',
|
||||
existing_type=sa.String(length=10000),
|
||||
type_=sa.TEXT(),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add is_root column
|
||||
|
||||
Revision ID: 8d4dced54966
|
||||
Revises: cfbee4f91fc2
|
||||
Create Date: 2026-02-24 11:15:51.402384
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8d4dced54966'
|
||||
down_revision: Union[str, None] = 'cfbee4f91fc2'
|
||||
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.add_column('drive_comments', sa.Column('author_id', sa.Integer(), nullable=True))
|
||||
op.add_column('drive_comments', sa.Column('body', sa.Text(), nullable=True))
|
||||
|
||||
# Seeding existing comments if any (though usually clean)
|
||||
op.execute("UPDATE drive_comments SET author_id = user_id WHERE author_id IS NULL")
|
||||
op.execute("UPDATE drive_comments SET body = content WHERE body IS NULL")
|
||||
|
||||
op.alter_column('drive_comments', 'author_id', nullable=False)
|
||||
op.alter_column('drive_comments', 'body', nullable=False)
|
||||
|
||||
op.add_column('drive_comments', sa.Column('anchor', sa.Text(), nullable=True))
|
||||
op.add_column('drive_comments', sa.Column('resolved_at', sa.DateTime(), nullable=True))
|
||||
op.drop_constraint(op.f('drive_comments_user_id_fkey'), 'drive_comments', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_comments', 'users', ['author_id'], ['id'])
|
||||
op.drop_column('drive_comments', 'user_id')
|
||||
op.drop_column('drive_comments', 'content')
|
||||
|
||||
# 🆕 Add is_root safely
|
||||
op.add_column('drive_folders', sa.Column('is_root', sa.Boolean(), nullable=True))
|
||||
op.execute("UPDATE drive_folders SET is_root = false WHERE is_root IS NULL")
|
||||
op.execute("UPDATE drive_folders SET is_root = true WHERE parent_id IS NULL")
|
||||
op.alter_column('drive_folders', 'is_root', nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('drive_folders', 'is_root')
|
||||
op.add_column('drive_comments', sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False))
|
||||
op.add_column('drive_comments', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||
op.drop_constraint(None, 'drive_comments', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('drive_comments_user_id_fkey'), 'drive_comments', 'users', ['user_id'], ['id'])
|
||||
op.drop_column('drive_comments', 'resolved_at')
|
||||
op.drop_column('drive_comments', 'anchor')
|
||||
op.drop_column('drive_comments', 'body')
|
||||
op.drop_column('drive_comments', 'author_id')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add plan_roles and is_system to roles
|
||||
|
||||
Revision ID: 905726af73c8
|
||||
Revises: 80be2c61e7fc
|
||||
Create Date: 2026-08-20 19:17:20.939383
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '905726af73c8'
|
||||
down_revision: Union[str, None] = '80be2c61e7fc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('plan_roles',
|
||||
sa.Column('plan_id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('plan_id', 'role_id')
|
||||
)
|
||||
op.add_column('roles', sa.Column('is_system', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('roles', 'is_system')
|
||||
op.drop_table('plan_roles')
|
||||
@@ -0,0 +1,415 @@
|
||||
"""tenant_storage_config_and_provider
|
||||
|
||||
Revision ID: 9557c7307575
|
||||
Revises: h004_signing_forensic_v2
|
||||
Create Date: 2026-03-16 13:00:58.602085
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9557c7307575'
|
||||
down_revision: Union[str, None] = 'h004_signing_forensic_v2'
|
||||
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.execute("UPDATE drive_files SET size = 0 WHERE size IS NULL")
|
||||
op.execute("UPDATE drive_files SET is_starred = false WHERE is_starred IS NULL")
|
||||
op.execute("UPDATE drive_files SET is_trashed = false WHERE is_trashed IS NULL")
|
||||
op.execute("UPDATE drive_files SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
op.execute("UPDATE drive_files SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL")
|
||||
|
||||
op.execute("UPDATE drive_folders SET is_starred = false WHERE is_starred IS NULL")
|
||||
op.execute("UPDATE drive_folders SET is_trashed = false WHERE is_trashed IS NULL")
|
||||
op.execute("UPDATE drive_folders SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
op.execute("UPDATE drive_folders SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL")
|
||||
|
||||
op.execute("UPDATE drive_shares SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
|
||||
op.execute("UPDATE project_versions SET is_current = false WHERE is_current IS NULL")
|
||||
op.execute("UPDATE project_versions SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
|
||||
op.execute("UPDATE projects SET status = 'processing' WHERE status IS NULL")
|
||||
op.execute("UPDATE projects SET file_type = 'PDF' WHERE file_type IS NULL")
|
||||
op.execute("UPDATE projects SET output_format = 'EPUB' WHERE output_format IS NULL")
|
||||
op.execute("UPDATE projects SET progress = 0 WHERE progress IS NULL")
|
||||
op.execute("UPDATE projects SET current_step = 'upload' WHERE current_step IS NULL")
|
||||
op.execute("UPDATE projects SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
op.execute("UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL")
|
||||
|
||||
op.execute("UPDATE signing_requests SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
|
||||
op.execute("UPDATE tenants SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
op.execute("UPDATE tenants SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL")
|
||||
|
||||
op.execute("UPDATE users SET subscription = 'free' WHERE subscription IS NULL")
|
||||
op.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
||||
|
||||
op.create_table('tenant_storage_configs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('storage_provider', sa.String(length=50), nullable=False),
|
||||
sa.Column('b2_key_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('b2_application_key', sa.String(length=512), nullable=True),
|
||||
sa.Column('b2_bucket_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op:
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_storage_configs_tenant_id ON tenant_storage_configs (tenant_id)")
|
||||
|
||||
with op.batch_alter_table('accesses', schema=None) as batch_op:
|
||||
batch_op.alter_column('name',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=False)
|
||||
op.execute("DROP INDEX IF EXISTS ix_accesses_name")
|
||||
batch_op.drop_constraint(batch_op.f('uq_accesses_access_code'), type_='unique')
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_accesses_access_code ON accesses (access_code)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_accesses_category ON accesses (category)")
|
||||
op.execute("ALTER TABLE accesses DROP COLUMN IF EXISTS description")
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_activities_actor")
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_activities_res")
|
||||
op.execute("ALTER TABLE drive_activities DROP COLUMN IF EXISTS metadata")
|
||||
|
||||
with op.batch_alter_table('drive_file_versions', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('storage_provider', sa.String(length=50), nullable=False, server_default='minio'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_versions_file")
|
||||
|
||||
with op.batch_alter_table('drive_files', schema=None) as batch_op:
|
||||
batch_op.alter_column('size',
|
||||
existing_type=sa.BIGINT(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column('is_starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('is_trashed',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_files_folder")
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_files_owner")
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_files_trashed")
|
||||
|
||||
with op.batch_alter_table('drive_folders', schema=None) as batch_op:
|
||||
batch_op.alter_column('is_starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('is_trashed',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_folders_owner")
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_folders_parent")
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_folders_trashed")
|
||||
|
||||
with op.batch_alter_table('drive_shares', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_drive_shares_resource")
|
||||
op.execute("DROP INDEX IF EXISTS uq_drive_shares_link")
|
||||
batch_op.create_unique_constraint(None, ['link_token'])
|
||||
batch_op.drop_constraint(batch_op.f('drive_shares_created_by_id_fkey'), type_='foreignkey')
|
||||
batch_op.create_foreign_key(None, 'users', ['created_by_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
with op.batch_alter_table('project_versions', schema=None) as batch_op:
|
||||
batch_op.alter_column('is_current',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_project_versions_created_by")
|
||||
op.execute("DROP INDEX IF EXISTS idx_project_versions_project_id")
|
||||
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
batch_op.alter_column('status',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'processing'::character varying"))
|
||||
batch_op.alter_column('file_type',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'PDF'::character varying"))
|
||||
batch_op.alter_column('output_format',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'EPUB'::character varying"))
|
||||
batch_op.alter_column('progress',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column('current_step',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'upload'::character varying"))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_projects_session_id")
|
||||
op.execute("DROP INDEX IF EXISTS idx_projects_user_id")
|
||||
batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
with op.batch_alter_table('signing_requests', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
|
||||
with op.batch_alter_table('system_configurations', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_system_configurations_config_key'), type_='unique')
|
||||
|
||||
with op.batch_alter_table('tenant_smtp_configs', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_tenant_smtp_configs_tenant_id'), type_='unique')
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenant_smtp_configs_tenant_id")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_smtp_configs_tenant_id ON tenant_smtp_configs (tenant_id)")
|
||||
|
||||
with op.batch_alter_table('tenants', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
batch_op.drop_constraint(batch_op.f('tenants_slug_key'), type_='unique')
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_tenants_id ON tenants (id)")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenants_slug ON tenants (slug)")
|
||||
|
||||
with op.batch_alter_table('user_files', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('storage_provider', sa.String(length=50), nullable=False, server_default='minio'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_user_files_s3_key")
|
||||
op.execute("DROP INDEX IF EXISTS idx_user_files_user")
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.alter_column('subscription',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'free'::character varying"))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
op.execute("DROP INDEX IF EXISTS idx_users_email")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_users_email ON users (email)")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
op.execute("DROP INDEX IF EXISTS ix_users_email")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)")
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('subscription',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'free'::character varying"))
|
||||
|
||||
with op.batch_alter_table('user_files', schema=None) as batch_op:
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_user_files_user ON user_files (user_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_user_files_s3_key ON user_files (s3_key)")
|
||||
op.execute("ALTER TABLE user_files DROP COLUMN IF EXISTS storage_provider")
|
||||
|
||||
with op.batch_alter_table('tenants', schema=None) as batch_op:
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenants_slug")
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenants_id")
|
||||
batch_op.create_unique_constraint(batch_op.f('tenants_slug_key'), ['slug'], postgresql_nulls_not_distinct=False)
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
|
||||
with op.batch_alter_table('tenant_smtp_configs', schema=None) as batch_op:
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenant_smtp_configs_tenant_id")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_tenant_smtp_configs_tenant_id ON tenant_smtp_configs (tenant_id)")
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_tenant_smtp_configs_tenant_id'), ['tenant_id'], postgresql_nulls_not_distinct=False)
|
||||
|
||||
with op.batch_alter_table('system_configurations', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_system_configurations_config_key'), ['config_key'], postgresql_nulls_not_distinct=False)
|
||||
|
||||
with op.batch_alter_table('signing_requests', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
|
||||
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects (user_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_projects_session_id ON projects (session_id)")
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('current_step',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'upload'::character varying"))
|
||||
batch_op.alter_column('progress',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column('output_format',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'EPUB'::character varying"))
|
||||
batch_op.alter_column('file_type',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'PDF'::character varying"))
|
||||
batch_op.alter_column('status',
|
||||
existing_type=sa.VARCHAR(length=50),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'processing'::character varying"))
|
||||
|
||||
with op.batch_alter_table('project_versions', schema=None) as batch_op:
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_project_versions_project_id ON project_versions (project_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_project_versions_created_by ON project_versions (created_by_id)")
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('is_current',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
|
||||
with op.batch_alter_table('drive_shares', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.create_foreign_key(batch_op.f('drive_shares_created_by_id_fkey'), 'users', ['created_by_id'], ['id'])
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
op.execute("CREATE INDEX IF NOT EXISTS uq_drive_shares_link ON drive_shares (link_token)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_shares_resource ON drive_shares (resource_type,resource_id)")
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
|
||||
with op.batch_alter_table('drive_folders', schema=None) as batch_op:
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_trashed ON drive_folders (is_trashed)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_parent ON drive_folders (parent_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_owner ON drive_folders (owner_id)")
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('is_trashed',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('is_starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
|
||||
with op.batch_alter_table('drive_files', schema=None) as batch_op:
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_trashed ON drive_files (is_trashed)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_owner ON drive_files (owner_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_folder ON drive_files (folder_id)")
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
|
||||
batch_op.alter_column('is_trashed',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('is_starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
batch_op.alter_column('size',
|
||||
existing_type=sa.BIGINT(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('0'))
|
||||
|
||||
with op.batch_alter_table('drive_file_versions', schema=None) as batch_op:
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_versions_file ON drive_file_versions (file_id)")
|
||||
op.execute("ALTER TABLE drive_file_versions DROP COLUMN IF EXISTS storage_provider")
|
||||
|
||||
with op.batch_alter_table('drive_activities', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('metadata', sa.TEXT(), autoincrement=False, nullable=True))
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_activities_res ON drive_activities (resource_type,resource_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS idx_drive_activities_actor ON drive_activities (actor_id)")
|
||||
|
||||
with op.batch_alter_table('accesses', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('description', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||
op.execute("DROP INDEX IF EXISTS ix_accesses_category")
|
||||
op.execute("DROP INDEX IF EXISTS ix_accesses_access_code")
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_accesses_access_code'), ['access_code'], postgresql_nulls_not_distinct=False)
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_accesses_name ON accesses (name)")
|
||||
batch_op.alter_column('name',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=100),
|
||||
existing_nullable=False)
|
||||
|
||||
with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op:
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenant_storage_configs_tenant_id")
|
||||
|
||||
op.execute("DROP TABLE IF EXISTS tenant_storage_configs")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Make DriveComment generic
|
||||
|
||||
Revision ID: 9a6db38b86a6
|
||||
Revises: 41bed7eb6ed9
|
||||
Create Date: 2026-02-27 14:32:05.071480
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9a6db38b86a6'
|
||||
down_revision: Union[str, None] = '41bed7eb6ed9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add columns as nullable first
|
||||
op.add_column('drive_comments', sa.Column('resource_type', sa.String(length=20), nullable=True))
|
||||
op.add_column('drive_comments', sa.Column('resource_id', sa.Integer(), nullable=True))
|
||||
|
||||
# 2. Populate data from existing file_id safely
|
||||
# Use WHERE file_id IS NOT NULL to avoid unnecessary or invalid updates
|
||||
op.execute("UPDATE drive_comments SET resource_type = 'file', resource_id = file_id WHERE file_id IS NOT NULL")
|
||||
|
||||
# 3. Set to NOT NULL now that data is populated
|
||||
op.alter_column('drive_comments', 'resource_type', nullable=False)
|
||||
op.alter_column('drive_comments', 'resource_id', nullable=False)
|
||||
|
||||
# 4. Cleanup old column and constraint
|
||||
with op.batch_alter_table('drive_comments') as batch_op:
|
||||
batch_op.drop_column('file_id')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Add column back as nullable first
|
||||
op.add_column('drive_comments', sa.Column('file_id', sa.Integer(), nullable=True))
|
||||
|
||||
# 2. Restore data from generic columns back to file_id
|
||||
# Only migrate records that were originally or are currently 'file' types
|
||||
op.execute("UPDATE drive_comments SET file_id = resource_id WHERE resource_type = 'file'")
|
||||
|
||||
# 3. Delete or handle non-file comments that cannot exist in the old schema
|
||||
# Alternatively, keep file_id as nullable if preferred, but here we restore original state
|
||||
op.execute("DELETE FROM drive_comments WHERE resource_type != 'file'")
|
||||
|
||||
# 4. Restore constraints
|
||||
op.alter_column('drive_comments', 'file_id', nullable=False)
|
||||
op.create_foreign_key('drive_comments_file_id_fkey', 'drive_comments', 'drive_files', ['file_id'], ['id'])
|
||||
|
||||
# 5. Cleanup generic columns
|
||||
op.drop_column('drive_comments', 'resource_id')
|
||||
op.drop_column('drive_comments', 'resource_type')
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge heads
|
||||
|
||||
Revision ID: 9af99fc7ca0f
|
||||
Revises: 15f935213b22, cba70ab35793, v3_extraction
|
||||
Create Date: 2026-04-18 14:22:43.985119
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9af99fc7ca0f'
|
||||
down_revision: Union[str, None] = ('15f935213b22', 'cba70ab35793', 'v3_extraction')
|
||||
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,79 @@
|
||||
"""Add device and session tables
|
||||
|
||||
Revision ID: 9c49fe120ffb
|
||||
Revises: z004_add_global_workflows
|
||||
Create Date: 2026-08-06 15:27:12.015009
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9c49fe120ffb'
|
||||
down_revision: Union[str, None] = 'z004_add_global_workflows'
|
||||
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('devices',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('fingerprint', sa.String(length=255), nullable=False),
|
||||
sa.Column('browser', sa.String(length=100), nullable=True),
|
||||
sa.Column('os', sa.String(length=100), nullable=True),
|
||||
sa.Column('device_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('user_agent', sa.String(length=512), nullable=True),
|
||||
sa.Column('ip', sa.String(length=50), nullable=True),
|
||||
sa.Column('country', sa.String(length=100), nullable=True),
|
||||
sa.Column('state', sa.String(length=100), nullable=True),
|
||||
sa.Column('city', sa.String(length=100), nullable=True),
|
||||
sa.Column('latitude', sa.Float(), nullable=True),
|
||||
sa.Column('longitude', sa.Float(), nullable=True),
|
||||
sa.Column('first_login', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_login', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_ip', sa.String(length=50), nullable=True),
|
||||
sa.Column('is_blocked', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_devices_fingerprint'), 'devices', ['fingerprint'], unique=False)
|
||||
op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False)
|
||||
op.create_table('sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('device_id', sa.Integer(), nullable=True),
|
||||
sa.Column('refresh_token_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('last_activity', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoked_by', sa.String(length=50), nullable=True),
|
||||
sa.Column('revoked_by_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('reason', sa.String(length=255), nullable=True),
|
||||
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['revoked_by_user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sessions_device_id'), 'sessions', ['device_id'], unique=False)
|
||||
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
||||
op.drop_index(op.f('ix_sessions_device_id'), table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
op.drop_index(op.f('ix_devices_user_id'), table_name='devices')
|
||||
op.drop_index(op.f('ix_devices_fingerprint'), table_name='devices')
|
||||
op.drop_table('devices')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add SaaSRoleMapping
|
||||
|
||||
Revision ID: 9c4a95ac6726
|
||||
Revises: 905726af73c8
|
||||
Create Date: 2026-08-28 15:36:54.084287
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9c4a95ac6726'
|
||||
down_revision: Union[str, None] = '905726af73c8'
|
||||
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('saas_role_mappings',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('saas_role_id', sa.String(), nullable=False),
|
||||
sa.Column('docqube_role_id', sa.UUID(), nullable=False),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['docqube_role_id'], ['roles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_saas_role_mappings_id'), 'saas_role_mappings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_saas_role_mappings_saas_role_id'), 'saas_role_mappings', ['saas_role_id'], unique=True)
|
||||
op.execute("ALTER TABLE tenant_contacts DROP CONSTRAINT IF EXISTS uq_tenant_contacts_tenant_id_email")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_unique_constraint('uq_tenant_contacts_tenant_id_email', 'tenant_contacts', ['tenant_id', 'email'])
|
||||
op.drop_index(op.f('ix_saas_role_mappings_saas_role_id'), table_name='saas_role_mappings')
|
||||
op.drop_index(op.f('ix_saas_role_mappings_id'), table_name='saas_role_mappings')
|
||||
op.drop_table('saas_role_mappings')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Limit ChatbotDocument file_path and filename to 512 chars
|
||||
|
||||
Revision ID: 9f6521a38bf3
|
||||
Revises: 3aa7d970b8c2
|
||||
Create Date: 2026-03-03 22:12:00.390551
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9f6521a38bf3'
|
||||
down_revision: Union[str, None] = '3aa7d970b8c2'
|
||||
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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add RBAC tables (accesses, roles, role_accesses) and role_id to users
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: ff9052a97835
|
||||
Create Date: 2026-03-05 11:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = ("6c9cc3567866", "ff9052a97835")
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create accesses table
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE accesses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_accesses_name ON accesses (name);")
|
||||
|
||||
# 2. Create roles table (tenant_id nullable = system-wide role)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
CONSTRAINT uq_role_tenant_name UNIQUE (tenant_id, name)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_roles_tenant_id ON roles (tenant_id);")
|
||||
|
||||
# 3. Create role_accesses join table (composite PK)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE role_accesses (
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
access_id INTEGER NOT NULL REFERENCES accesses(id) ON DELETE CASCADE,
|
||||
CONSTRAINT pk_role_accesses PRIMARY KEY (role_id, access_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Add role_id to users (nullable FK, SET NULL on delete)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL;
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_users_role_id ON users (role_id);")
|
||||
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse in dependency order
|
||||
|
||||
# Remove role_id from users
|
||||
op.execute("DROP INDEX IF EXISTS ix_users_role_id;")
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS role_id;")
|
||||
|
||||
# Drop join table
|
||||
op.execute("DROP TABLE IF EXISTS role_accesses;")
|
||||
|
||||
# Drop roles table (seed data goes with it)
|
||||
op.execute("DROP INDEX IF EXISTS ix_roles_tenant_id;")
|
||||
op.execute("DROP TABLE IF EXISTS roles;")
|
||||
|
||||
# Drop accesses table
|
||||
op.execute("DROP INDEX IF EXISTS ix_accesses_name;")
|
||||
op.execute("DROP TABLE IF EXISTS accesses;")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Update Access model with hierarchical permission support
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-03-05 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a2b3c4d5e6f7'
|
||||
down_revision = 'a1b2c3d4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# FIRST: Drop any existing unique constraint on `name` (constraints may be named differently depending on DB)
|
||||
op.execute("ALTER TABLE accesses DROP CONSTRAINT IF EXISTS uq_accesses_name CASCADE")
|
||||
op.execute("ALTER TABLE accesses DROP CONSTRAINT IF EXISTS accesses_name_key CASCADE")
|
||||
# also drop postgresql-generated index if it survived
|
||||
op.execute("DROP INDEX IF EXISTS accesses_name_key")
|
||||
|
||||
# Add new columns to accesses table (all nullable first)
|
||||
op.add_column('accesses', sa.Column('access_code', sa.String(255), nullable=True))
|
||||
op.add_column('accesses', sa.Column('category', sa.String(100), nullable=True))
|
||||
op.add_column('accesses', sa.Column('parent_id', sa.Integer(), nullable=True))
|
||||
|
||||
# Create indexes on new columns
|
||||
op.create_index('ix_access_code', 'accesses', ['access_code'])
|
||||
op.create_index('ix_category', 'accesses', ['category'])
|
||||
op.create_index('ix_accesses_parent_id', 'accesses', ['parent_id'])
|
||||
|
||||
# Set default values from existing 'name' field
|
||||
op.execute("""
|
||||
UPDATE accesses
|
||||
SET access_code = LOWER(REPLACE(name, ' ', '_')),
|
||||
category = 'General'
|
||||
WHERE access_code IS NULL
|
||||
""")
|
||||
|
||||
# Now make columns NOT NULL
|
||||
op.alter_column('accesses', 'access_code', existing_type=sa.String(255), nullable=False)
|
||||
op.alter_column('accesses', 'category', existing_type=sa.String(100), nullable=False)
|
||||
|
||||
# Create unique constraint on access_code
|
||||
op.create_unique_constraint('uq_accesses_access_code', 'accesses', ['access_code'])
|
||||
|
||||
# Create foreign key for parent_id
|
||||
op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop foreign key
|
||||
op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey')
|
||||
|
||||
# Drop unique constraint
|
||||
op.drop_constraint('uq_accesses_access_code', 'accesses', type_='unique')
|
||||
|
||||
# Drop indexes
|
||||
op.drop_index('ix_accesses_parent_id', 'accesses')
|
||||
op.drop_index('ix_category', 'accesses')
|
||||
op.drop_index('ix_access_code', 'accesses')
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('accesses', 'parent_id')
|
||||
op.drop_column('accesses', 'category')
|
||||
op.drop_column('accesses', 'access_code')
|
||||
|
||||
# Re-add unique constraint on 'name'
|
||||
op.create_unique_constraint('uq_accesses_name', 'accesses', ['name'])
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Remove redundant id column from user_storage_usage
|
||||
|
||||
Revision ID: a902935ea69e
|
||||
Revises: 0e1a5f2cdea9
|
||||
Create Date: 2026-03-02 10:53:47.556155
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a902935ea69e'
|
||||
down_revision: Union[str, None] = '0e1a5f2cdea9'
|
||||
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.drop_index('ix_user_storage_usage_id', table_name='user_storage_usage')
|
||||
op.drop_constraint('user_storage_usage_user_id_key', 'user_storage_usage', type_='unique')
|
||||
op.drop_column('user_storage_usage', 'id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user_storage_usage', sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False))
|
||||
op.create_unique_constraint('user_storage_usage_user_id_key', 'user_storage_usage', ['user_id'], postgresql_nulls_not_distinct=False)
|
||||
op.create_index('ix_user_storage_usage_id', 'user_storage_usage', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,64 @@
|
||||
"""add signature config tables
|
||||
|
||||
Revision ID: af57c3d9f1c2
|
||||
Revises: daf45366d608
|
||||
Create Date: 2026-07-20 18:53:11.916379
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'af57c3d9f1c2'
|
||||
down_revision: Union[str, None] = 'daf45366d608'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'global_signature_configs',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('active_provider', sa.String(length=50), nullable=False, server_default='zoho_sign'),
|
||||
sa.Column('docuseal_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('zoho_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL')
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"INSERT INTO global_signature_configs (id, active_provider, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid(), 'zoho_sign', now(), now())"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'tenant_signature_configs',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('use_custom_credentials', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('custom_provider', sa.String(length=50), nullable=False, server_default='docuseal'),
|
||||
sa.Column('custom_credentials_json', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL')
|
||||
)
|
||||
op.create_index(op.f('ix_tenant_signature_configs_tenant_id'), 'tenant_signature_configs', ['tenant_id'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_tenant_signature_configs_tenant_id'), table_name='tenant_signature_configs')
|
||||
op.drop_table('tenant_signature_configs')
|
||||
op.drop_table('global_signature_configs')
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add_signature_id
|
||||
|
||||
Revision ID: b029d2f5a0c7
|
||||
Revises: fb6810c36ffb
|
||||
Create Date: 2026-04-14 14:59:09.399640
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b029d2f5a0c7'
|
||||
down_revision: Union[str, None] = 'fb6810c36ffb'
|
||||
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! ###
|
||||
with op.batch_alter_table('signing_requests', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('signature_id', sa.String(length=128), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_signing_requests_signature_id'), ['signature_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('signing_requests', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_signing_requests_signature_id'))
|
||||
batch_op.drop_column('signature_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Add persistent chat history tables
|
||||
|
||||
Revision ID: b0a09cd13348
|
||||
Revises: w001_versioning_minor
|
||||
Create Date: 2026-05-06 16:38:12.054824
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b0a09cd13348'
|
||||
down_revision: Union[str, None] = 'w001_versioning_minor'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
conn = op.get_bind()
|
||||
inspector = Inspector.from_engine(conn)
|
||||
|
||||
if not inspector.has_table('activity_logs'):
|
||||
op.create_table('activity_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('user_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('user_email', sa.String(length=255), nullable=True),
|
||||
sa.Column('module', sa.String(length=50), nullable=False),
|
||||
sa.Column('action', sa.String(length=100), nullable=False),
|
||||
sa.Column('target_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('target_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('target_name', sa.String(length=512), nullable=True),
|
||||
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=64), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('request_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('activity_logs', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_activity_logs_module_action', ['module', 'action'], unique=False)
|
||||
batch_op.create_index('ix_activity_logs_target_id', ['target_id'], unique=False)
|
||||
batch_op.create_index('ix_activity_logs_tenant_created_at', ['tenant_id', 'created_at'], unique=False)
|
||||
batch_op.create_index('ix_activity_logs_user_created_at', ['user_id', 'created_at'], unique=False)
|
||||
|
||||
if not inspector.has_table('chat_sessions'):
|
||||
op.create_table('chat_sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('chatbot_document_id', sa.Integer(), nullable=True),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['chatbot_document_id'], ['chatbot_documents.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('chat_sessions', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_chat_sessions_chatbot_document_id'), ['chatbot_document_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_sessions_session_id'), ['session_id'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_chat_sessions_tenant_id'), ['tenant_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chat_sessions_user_id'), ['user_id'], unique=False)
|
||||
|
||||
if not inspector.has_table('chat_messages'):
|
||||
op.create_table('chat_messages',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('message_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('token_count', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('message_id')
|
||||
)
|
||||
with op.batch_alter_table('chat_messages', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_chat_messages_session_id'), ['session_id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('drive_file_versions', schema=None) as batch_op:
|
||||
# Check if index exists before dropping
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes('drive_file_versions')]
|
||||
if 'ix_drive_file_versions_file_major_minor' in indexes:
|
||||
batch_op.drop_index('ix_drive_file_versions_file_major_minor')
|
||||
|
||||
with op.batch_alter_table('signature_imprints', schema=None) as batch_op:
|
||||
# Check if index exists before dropping
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes('signature_imprints')]
|
||||
if 'ix_signature_imprints_version_id' in indexes:
|
||||
batch_op.drop_index('ix_signature_imprints_version_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('signature_imprints', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_signature_imprints_version_id', ['drive_file_version_id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('drive_file_versions', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_drive_file_versions_file_major_minor', ['file_id', 'version_number', 'minor_version'], unique=False)
|
||||
|
||||
with op.batch_alter_table('chat_messages', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_chat_messages_session_id'))
|
||||
|
||||
op.drop_table('chat_messages')
|
||||
with op.batch_alter_table('chat_sessions', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_chat_sessions_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_chat_sessions_tenant_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_chat_sessions_session_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_chat_sessions_chatbot_document_id'))
|
||||
|
||||
op.drop_table('chat_sessions')
|
||||
with op.batch_alter_table('activity_logs', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_activity_logs_user_created_at')
|
||||
batch_op.drop_index('ix_activity_logs_tenant_created_at')
|
||||
batch_op.drop_index('ix_activity_logs_target_id')
|
||||
batch_op.drop_index('ix_activity_logs_module_action')
|
||||
|
||||
op.drop_table('activity_logs')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,62 @@
|
||||
"""B1.0 — make superadmin an explicit flag instead of a null tenant
|
||||
|
||||
Adds users.is_superadmin and backfills it from the signal it replaces, so the
|
||||
operators who are superadmins today stay superadmins after deploy.
|
||||
|
||||
Until now privilege was inferred from `tenant_id IS NULL`. That made the
|
||||
*absence* of tenant context a grant of authority: a bug that dropped the tenant
|
||||
escalated instead of denying. The flag separates "has no tenant" from "may do
|
||||
anything".
|
||||
|
||||
This migration only adds and backfills. It deliberately does not drop or alter
|
||||
`tenant_id`, and the application honours both signals for one release, so this
|
||||
can be reverted without locking anyone out.
|
||||
|
||||
Revision ID: b1_0_explicit_superadmin
|
||||
Revises: dfae38e4ede3
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "b1_0_explicit_superadmin"
|
||||
down_revision = "73dff81eed72"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"is_superadmin",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
# Backfill in the same migration, not a follow-up script. If this ran later
|
||||
# there would be a window in which every superadmin was demoted.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET is_superadmin = true
|
||||
WHERE tenant_id IS NULL
|
||||
AND is_deleted = false
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_users_is_superadmin",
|
||||
"users",
|
||||
["is_superadmin"],
|
||||
postgresql_where=sa.text("is_superadmin"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Safe to reverse: `tenant_id IS NULL` still identifies the same people,
|
||||
# and the application still honours it.
|
||||
op.drop_index("ix_users_is_superadmin", table_name="users")
|
||||
op.drop_column("users", "is_superadmin")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""B1.3 — row-level security on every tenant-owned table
|
||||
|
||||
Enables **and forces** RLS on the 15 tables carrying a `tenant_id`, with a policy
|
||||
that compares the row's tenant against a session variable the application sets
|
||||
per request.
|
||||
|
||||
Why this exists when B1.1 already filters queries: the ORM listener attaches to
|
||||
entity loading, so it cannot filter aggregates — `query(X).count()` compiles to
|
||||
`SELECT count(*) FROM (SELECT ...)` and comes back unfiltered. RLS applies in the
|
||||
database regardless of how the query was composed, so it closes a hole the
|
||||
listener structurally cannot.
|
||||
|
||||
FORCE matters. Without it the policy does not apply to the table's owner, and
|
||||
migrations run as the owner — so the tables would look protected while the
|
||||
application, if it ever connected as that role, saw everything.
|
||||
|
||||
This migration is inert until the application sets `docqube.tenant_id`, and
|
||||
takes effect fully only once the app connects as a NOSUPERUSER / NOBYPASSRLS
|
||||
role (B1.4). A superuser connection bypasses RLS unconditionally.
|
||||
|
||||
Revision ID: b1_3_row_level_security
|
||||
Revises: b1_0_explicit_superadmin
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "b1_3_row_level_security"
|
||||
down_revision = "b1_0_explicit_superadmin"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
POLICY = "docqube_tenant_isolation"
|
||||
|
||||
# Derived from the schema when this was written; asserted against
|
||||
# information_schema by tests/probes/test_rls.py so drift is caught rather than
|
||||
# assumed.
|
||||
TENANT_TABLES = [
|
||||
"activity_logs",
|
||||
"chat_sessions",
|
||||
"chatbot_documents",
|
||||
"drive_activities",
|
||||
"drive_files",
|
||||
"drive_folders",
|
||||
"notifications",
|
||||
"projects",
|
||||
"roles",
|
||||
"signing_requests",
|
||||
"tenant_signature_configs",
|
||||
"tenant_smtp_configs",
|
||||
"tenant_storage_configs",
|
||||
"user_files",
|
||||
"users",
|
||||
]
|
||||
|
||||
# The predicate, in words:
|
||||
# * `docqube.bypass = 'on'` — an explicit system operation (migrations,
|
||||
# background sweeps). Only the application can set this; it is not reachable
|
||||
# from user input.
|
||||
# * the row has no tenant — shared system records such as global roles.
|
||||
# * the row's tenant matches the session variable.
|
||||
#
|
||||
# With no variable set, `current_setting(..., true)` returns NULL, `NULLIF`
|
||||
# keeps it NULL, and `tenant_id = NULL` is NULL — so only shared rows are
|
||||
# visible. Losing the tenant denies rather than reveals.
|
||||
USING = """
|
||||
coalesce(current_setting('docqube.bypass', true), '') = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
op.execute(
|
||||
f"CREATE POLICY {POLICY} ON {table} "
|
||||
f"USING ({USING}) WITH CHECK ({USING})"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add collaboration_messages table
|
||||
|
||||
Revision ID: b3a7c1e9f042
|
||||
Revises: f055f9b157cb
|
||||
Create Date: 2026-03-02 15:17:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b3a7c1e9f042'
|
||||
down_revision: Union[str, None] = 'f055f9b157cb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"collaboration_messages",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"file_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("drive_files.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"sender_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
# Performance indexes
|
||||
op.create_index("ix_collab_msg_file_id", "collaboration_messages", ["file_id"])
|
||||
op.create_index("ix_collab_msg_sender_id", "collaboration_messages", ["sender_id"])
|
||||
op.create_index("ix_collab_msg_created_at", "collaboration_messages", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_collab_msg_created_at", table_name="collaboration_messages")
|
||||
op.drop_index("ix_collab_msg_sender_id", table_name="collaboration_messages")
|
||||
op.drop_index("ix_collab_msg_file_id", table_name="collaboration_messages")
|
||||
op.drop_table("collaboration_messages")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""B6.4 — indexes on the foreign keys that queries actually filter by
|
||||
|
||||
The database has 34 foreign keys with no supporting index. PostgreSQL does not
|
||||
create one automatically, and the cost is invisible on a seeded database: a
|
||||
sequential scan over five rows is instant, over five hundred thousand it is not.
|
||||
|
||||
This adds indexes only where a measured query path filters or joins on the
|
||||
column — the ones surfaced by the B6.3 query-budget work. Indexing all 34 would
|
||||
add write cost for paths nothing traverses; the remainder are listed in
|
||||
`tests/probes/test_index_coverage.py` so they stay visible rather than
|
||||
forgotten.
|
||||
|
||||
**Deployment note.** `CREATE INDEX` takes a lock that blocks writes for the
|
||||
duration. On DocQube's current row counts that is brief, but on a large
|
||||
`drive_files` it will not be — run these with `CONCURRENTLY` outside a
|
||||
transaction if the table has grown. Alembic cannot do that inside its own
|
||||
transaction, which is why this migration is written to be easy to translate.
|
||||
|
||||
Revision ID: b6_4_index_review
|
||||
Revises: b1_3_row_level_security
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "b6_4_index_review"
|
||||
down_revision = "b1_3_row_level_security"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# (index name, table, columns) — each justified by a query path measured in
|
||||
# tests/characterization/test_query_budget.py.
|
||||
INDEXES = [
|
||||
# Every folder listing filters files by folder.
|
||||
("ix_drive_files_folder_id", "drive_files", "folder_id"),
|
||||
("ix_drive_files_owner_id", "drive_files", "owner_id"),
|
||||
# Hierarchy walks in get_effective_role and breadcrumbs.
|
||||
("ix_drive_folders_parent_id", "drive_folders", "parent_id"),
|
||||
("ix_drive_folders_owner_id", "drive_folders", "owner_id"),
|
||||
# The batched star lookup added in B6.3 filters on all three.
|
||||
(
|
||||
"ix_drive_stars_user_resource",
|
||||
"drive_stars",
|
||||
"user_id, resource_type, resource_id",
|
||||
),
|
||||
# Version history, loaded per file.
|
||||
("ix_drive_file_versions_file_id", "drive_file_versions", "file_id"),
|
||||
("ix_project_versions_project_id", "project_versions", "project_id"),
|
||||
# Permission resolution joins accesses on every authenticated request.
|
||||
("ix_role_accesses_access_id", "role_accesses", "access_id"),
|
||||
# Signing looks requests up by the file they belong to.
|
||||
("ix_signing_requests_drive_file_id", "signing_requests", "drive_file_id"),
|
||||
# Comments and activity are read per resource.
|
||||
("ix_drive_comments_author_id", "drive_comments", "author_id"),
|
||||
("ix_drive_activities_actor_id", "drive_activities", "actor_id"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for name, table, columns in INDEXES:
|
||||
op.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({columns})")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name, _table, _columns in INDEXES:
|
||||
op.execute(f"DROP INDEX IF EXISTS {name}")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""remove_tenant_storage_config_and_use_system_config
|
||||
|
||||
Revision ID: bb224e0b642b
|
||||
Revises: i001_dual_bucket_storage
|
||||
Create Date: 2026-03-17 09:09:02.599878
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bb224e0b642b'
|
||||
down_revision: Union[str, None] = 'i001_dual_bucket_storage'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the tenant-specific storage config table
|
||||
op.drop_table('tenant_storage_configs')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the tenant-specific storage config table
|
||||
op.create_table(
|
||||
'tenant_storage_configs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('storage_provider', sa.String(length=50), nullable=False),
|
||||
sa.Column('b2_key_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('b2_application_key', sa.String(length=512), nullable=True),
|
||||
sa.Column('b2_quarantine_bucket', sa.String(length=255), nullable=True),
|
||||
sa.Column('b2_clean_bucket', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_by_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
op.create_index(op.f('ix_tenant_storage_configs_tenant_id'), 'tenant_storage_configs', ['tenant_id'], unique=True)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add encrypted_ai_api_key to user_model
|
||||
|
||||
Revision ID: be67927ab00d
|
||||
Revises: b3a7c1e9f042
|
||||
Create Date: 2026-03-03 12:43:12.848347
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'be67927ab00d'
|
||||
down_revision: Union[str, None] = 'b3a7c1e9f042'
|
||||
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.alter_column('collaboration_messages', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.add_column('users', sa.Column('encrypted_ai_api_key', sa.String(length=512), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'encrypted_ai_api_key')
|
||||
op.alter_column('collaboration_messages', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,247 @@
|
||||
"""C1/C3 — organisation units, membership, leadership, and scoped role grants
|
||||
|
||||
Four tables, and a backfill that is deliberately a no-op in behaviour.
|
||||
|
||||
`user_roles` exists because `users.role_id` is single-valued: one role, held
|
||||
across the whole tenant. Scoped access has nowhere to live until a user can hold
|
||||
the same role in two units. The backfill turns each existing `users.role_id`
|
||||
into one `user_roles` row with `org_unit_id = NULL`, which means tenant-wide —
|
||||
exactly the authority that user already had. Nobody gains or loses anything on
|
||||
this migration.
|
||||
|
||||
`users.role_id` is **not** dropped. Both mechanisms are honoured for one
|
||||
release, the same shape as the `is_superadmin` transition and for the same
|
||||
reason: reverting this migration must not be able to remove everyone's
|
||||
permissions. Removal is a later, deliberate step with its own preflight.
|
||||
|
||||
All four tables carry `tenant_id`, so `app/core/tenant_filter.py` scopes them
|
||||
automatically, and they are added to the RLS policy here because
|
||||
`tests/probes/test_isolation_ratchet.py` fails if a tenant-owned table is left
|
||||
unprotected.
|
||||
|
||||
Revision ID: c1_0_org_units_and_scoped_roles
|
||||
Revises: b6_4_index_review
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "c1_0_org_units_and_scoped_roles"
|
||||
down_revision = "b6_4_index_review"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Kept in step with b1_3_row_level_security.py.
|
||||
# Named TENANT_TABLES so `test_isolation_ratchet.py` finds it: the ratchet
|
||||
# scans every migration for this constant, which is what lets a later
|
||||
# migration add tenant-owned tables without the check going stale.
|
||||
TENANT_TABLES = ["org_units", "user_org_units", "org_unit_leads"]
|
||||
POLICY = "tenant_isolation"
|
||||
USING = """
|
||||
coalesce(current_setting('docqube.bypass', true), '') = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_units",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# SET NULL, not CASCADE: deleting a department must not silently delete
|
||||
# every team beneath it. What happens to orphans is an application
|
||||
# decision, and it should be visible as one.
|
||||
sa.Column(
|
||||
"parent_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("code", sa.String(50), nullable=True),
|
||||
sa.Column("unit_type", sa.String(50), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("depth", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_org_units_tenant_id", "org_units", ["tenant_id"])
|
||||
op.create_index("ix_org_units_parent_id", "org_units", ["parent_id"])
|
||||
op.create_index("ix_org_units_tenant_parent", "org_units", ["tenant_id", "parent_id"])
|
||||
# Codes are unique per tenant among *live* rows only — a soft-deleted unit
|
||||
# must not reserve its code forever.
|
||||
op.create_index(
|
||||
"uq_org_units_tenant_code_live",
|
||||
"org_units",
|
||||
["tenant_id", "code"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("NOT is_deleted AND code IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_org_units",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"org_unit_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_org_units_user_unit"),
|
||||
)
|
||||
op.create_index("ix_user_org_units_tenant_id", "user_org_units", ["tenant_id"])
|
||||
op.create_index("ix_user_org_units_user_id", "user_org_units", ["user_id"])
|
||||
op.create_index("ix_user_org_units_org_unit_id", "user_org_units", ["org_unit_id"])
|
||||
# "Exactly one primary" as an index, not as application logic: written in
|
||||
# Python it is a race between two concurrent writes, which is the same
|
||||
# defect class as the user_storage_usage get-or-create found under load.
|
||||
op.create_index(
|
||||
"uq_user_org_units_one_primary",
|
||||
"user_org_units",
|
||||
["user_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_primary"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"org_unit_leads",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"org_unit_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("start_date", sa.Date(), nullable=False),
|
||||
sa.Column("end_date", sa.Date(), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_org_unit_leads_tenant_id", "org_unit_leads", ["tenant_id"])
|
||||
op.create_index("ix_org_unit_leads_org_unit_id", "org_unit_leads", ["org_unit_id"])
|
||||
op.create_index("ix_org_unit_leads_user_id", "org_unit_leads", ["user_id"])
|
||||
# At most one *current* lead per unit; history is unconstrained, which is
|
||||
# what makes it usable as history.
|
||||
op.create_index(
|
||||
"uq_org_unit_leads_current",
|
||||
"org_unit_leads",
|
||||
["org_unit_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("end_date IS NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"role_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# NULL means tenant-wide, which is what every grant is today.
|
||||
sa.Column(
|
||||
"org_unit_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"assigned_by_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "role_id", "org_unit_id", name="uq_user_roles_scoped"),
|
||||
)
|
||||
op.create_index("ix_user_roles_user_id", "user_roles", ["user_id"])
|
||||
op.create_index("ix_user_roles_role_id", "user_roles", ["role_id"])
|
||||
op.create_index("ix_user_roles_org_unit_id", "user_roles", ["org_unit_id"])
|
||||
op.create_index("ix_user_roles_lookup", "user_roles", ["user_id", "role_id"])
|
||||
# Every foreign key needs a supporting index or a delete on the parent
|
||||
# sequentially scans this table. `test_index_coverage.py` enforces it.
|
||||
op.create_index("ix_user_roles_assigned_by_id", "user_roles", ["assigned_by_id"])
|
||||
# Postgres treats NULLs as distinct in a UNIQUE constraint, so the triple
|
||||
# above does not stop the same role being held tenant-wide twice.
|
||||
op.create_index(
|
||||
"uq_user_roles_tenant_wide",
|
||||
"user_roles",
|
||||
["user_id", "role_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("org_unit_id IS NULL"),
|
||||
)
|
||||
|
||||
# Backfill. Behaviour-neutral by construction: a null org unit is
|
||||
# tenant-wide, which is the authority every user already has.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO user_roles (id, user_id, role_id, org_unit_id, created_at)
|
||||
SELECT gen_random_uuid(), u.id, u.role_id, NULL, now()
|
||||
FROM users u
|
||||
WHERE u.role_id IS NOT NULL
|
||||
AND u.is_deleted = false
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Safe to reverse: `users.role_id` was never dropped, so removing these
|
||||
# tables returns the application to the grants it still reads today.
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
|
||||
op.drop_table("user_roles")
|
||||
op.drop_table("org_unit_leads")
|
||||
op.drop_table("user_org_units")
|
||||
op.drop_table("org_units")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""C3.1 — give user_roles a tenant_id, so isolation is structural
|
||||
|
||||
`user_roles` was created without one. That is not a cosmetic omission: both
|
||||
isolation layers *derive* their coverage from the presence of that column —
|
||||
`app/core/tenant_filter.py` scopes any model that has it, and the RLS policy
|
||||
compares it against the session variable. A table without it is protected by
|
||||
neither, no matter how careful the queries are.
|
||||
|
||||
It is the same shape of gap as `tenants`, which is where "any holder of
|
||||
`superadmin.tenant.delete` can delete any tenant" lived: a table outside the
|
||||
mechanism, guarded only by whatever each call site remembered to write. Three
|
||||
places currently remember. Three is a number that only goes up, and the failure
|
||||
mode when one forgets is a grant resolving across tenants.
|
||||
|
||||
After this the explicit predicates stay — they are cheap and they document the
|
||||
intent — but they stop being the only thing standing there.
|
||||
|
||||
**Backfill from the user, not the role.** `roles.tenant_id` is nullable: a null
|
||||
means a shared/global role template, and a grant of a shared role still belongs
|
||||
to the tenant of the person holding it. Rows for tenant-less users (superadmins)
|
||||
stay null, which the RLS policy already treats as globally visible — the same
|
||||
way their `users` row is treated.
|
||||
|
||||
Revision ID: c1_1_user_roles_tenant_id
|
||||
Revises: c1_0_org_units_and_scoped_roles
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "c1_1_user_roles_tenant_id"
|
||||
down_revision = "c1_0_org_units_and_scoped_roles"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TENANT_TABLES = ["user_roles"]
|
||||
POLICY = "tenant_isolation"
|
||||
USING = """
|
||||
coalesce(current_setting('docqube.bypass', true), '') = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user_roles",
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_user_roles_tenant_id", "user_roles", ["tenant_id"])
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE user_roles ur
|
||||
SET tenant_id = u.tenant_id
|
||||
FROM users u
|
||||
WHERE u.id = ur.user_id
|
||||
"""
|
||||
)
|
||||
|
||||
# Left nullable on purpose. A superadmin has no tenant, so their grants have
|
||||
# none either; forcing NOT NULL would mean inventing one.
|
||||
op.execute(f"ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE user_roles FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON user_roles")
|
||||
op.execute(f"CREATE POLICY {POLICY} ON user_roles USING ({USING}) WITH CHECK ({USING})")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON user_roles")
|
||||
op.execute("ALTER TABLE user_roles NO FORCE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE user_roles DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index("ix_user_roles_tenant_id", table_name="user_roles")
|
||||
op.drop_column("user_roles", "tenant_id")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""C3 — a materialised path, so a subtree is a prefix match
|
||||
|
||||
`ScopeService` resolved authority with a recursive CTE, once per access code per
|
||||
request. That is correct and it is the thing that gets slower as the tree gets
|
||||
deeper, in the way that shows up as "the admin page feels slow" rather than as
|
||||
an error — the query-count budgets cannot catch it, because the *count* does not
|
||||
change.
|
||||
|
||||
A materialised path turns the walk into a prefix match:
|
||||
|
||||
/a1b2.../c3d4.../e5f6.../
|
||||
|
||||
The subtree of a unit is every row whose path starts with that unit's path.
|
||||
Indexed with `text_pattern_ops`, that is an index range scan.
|
||||
|
||||
**Text, not `ltree`.** `ltree` is the tidier type and needs an extension, which
|
||||
is a deployment prerequisite for a performance improvement — a poor trade. Text
|
||||
with a prefix index needs nothing and behaves identically for this query.
|
||||
|
||||
**Why the delimiters matter.** Ids are stored between slashes, so
|
||||
`LIKE '/a/b/%'` cannot match `/a/bc/...`. Without them a prefix match would leak
|
||||
across sibling branches whose ids share a leading substring — which for UUIDs is
|
||||
rare, and rare is worse than never.
|
||||
|
||||
The column is nullable and the CTE remains as a fallback for any row that has
|
||||
not been backfilled, so this migration cannot break resolution even if the
|
||||
backfill is incomplete.
|
||||
|
||||
Revision ID: c3_0_org_unit_path
|
||||
Revises: c5_0_access_groups
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c3_0_org_unit_path"
|
||||
down_revision = "c5_0_access_groups"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("org_units", sa.Column("path", sa.Text(), nullable=True))
|
||||
|
||||
# Prefix matching needs `text_pattern_ops`; the default opclass supports
|
||||
# equality and ordering but not `LIKE 'prefix%'` as an index scan.
|
||||
op.execute(
|
||||
"CREATE INDEX ix_org_units_path_prefix "
|
||||
"ON org_units (path text_pattern_ops)"
|
||||
)
|
||||
op.create_index("ix_org_units_tenant_path", "org_units", ["tenant_id", "path"])
|
||||
|
||||
# Backfill, walking down from the roots. Done once here so the application
|
||||
# never has to compute a path it did not write.
|
||||
op.execute(
|
||||
"""
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, '/' || id::text || '/' AS path
|
||||
FROM org_units
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT c.id, t.path || c.id::text || '/'
|
||||
FROM org_units c
|
||||
JOIN tree t ON c.parent_id = t.id
|
||||
)
|
||||
UPDATE org_units u
|
||||
SET path = tree.path
|
||||
FROM tree
|
||||
WHERE u.id = tree.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_org_units_tenant_path", table_name="org_units")
|
||||
op.execute("DROP INDEX IF EXISTS ix_org_units_path_prefix")
|
||||
op.drop_column("org_units", "path")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""enforce_b2_storage_provider
|
||||
|
||||
Revision ID: c3abf44266de
|
||||
Revises: bb224e0b642b
|
||||
Create Date: 2026-03-17 10:54:51.326256
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3abf44266de'
|
||||
down_revision: Union[str, None] = 'bb224e0b642b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Update existing records from 'minio' to 'b2'
|
||||
op.execute("UPDATE user_files SET storage_provider = 'b2' WHERE storage_provider = 'minio'")
|
||||
op.execute("UPDATE drive_file_versions SET storage_provider = 'b2' WHERE storage_provider = 'minio'")
|
||||
|
||||
# 2. Change server default to 'b2'
|
||||
with op.batch_alter_table('user_files', schema=None) as batch_op:
|
||||
batch_op.alter_column('storage_provider', server_default='b2')
|
||||
|
||||
with op.batch_alter_table('drive_file_versions', schema=None) as batch_op:
|
||||
batch_op.alter_column('storage_provider', server_default='b2')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Change server default back to 'minio'
|
||||
with op.batch_alter_table('user_files', schema=None) as batch_op:
|
||||
batch_op.alter_column('storage_provider', server_default='minio')
|
||||
|
||||
with op.batch_alter_table('drive_file_versions', schema=None) as batch_op:
|
||||
batch_op.alter_column('storage_provider', server_default='minio')
|
||||
@@ -0,0 +1,46 @@
|
||||
"""C4A — grants can expire
|
||||
|
||||
`user_roles.expires_at`, nullable. Null means permanent, which is every existing
|
||||
row, so this migration changes nobody's authority.
|
||||
|
||||
Temporary elevation, contractor access and just-in-time grants all need this, and
|
||||
all three are ordinary enterprise asks. Without it the only way to end a grant is
|
||||
to remember to revoke it, and "remember to" is not an access control.
|
||||
|
||||
Expired rows are **not** deleted. They stay, and they become history — which is
|
||||
what C4B reads to answer "who had access in March". A swept-away row answers
|
||||
nothing.
|
||||
|
||||
Revision ID: c4_0_grant_expiry
|
||||
Revises: c1_1_user_roles_tenant_id
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c4_0_grant_expiry"
|
||||
down_revision = "c1_1_user_roles_tenant_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user_roles",
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# Partial: only live grants are ever queried by expiry, and indexing the
|
||||
# permanent ones (which is all of them today) would be indexing a column
|
||||
# that is null for every row.
|
||||
op.create_index(
|
||||
"ix_user_roles_expires_at",
|
||||
"user_roles",
|
||||
["expires_at"],
|
||||
postgresql_where=sa.text("expires_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_roles_expires_at", table_name="user_roles")
|
||||
op.drop_column("user_roles", "expires_at")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""C4C — index the access timeline, so retention and history are cheap
|
||||
|
||||
Two reads matter and neither had an index:
|
||||
|
||||
- the history endpoint, filtering one tenant's `access` events by date;
|
||||
- the retention sweeper, finding events older than the policy.
|
||||
|
||||
Both are `(tenant_id, module, created_at)`. Partial on `module = 'access'`,
|
||||
because ordinary activity is far more numerous and does not share these
|
||||
queries — indexing it here would triple the index for no reader.
|
||||
|
||||
Retention itself is a **setting**, not a constant: how long access records are
|
||||
kept is the sort of thing a customer contract specifies, and it must be
|
||||
answerable without a deploy. See `ACCESS_LOG_RETENTION_DAYS`.
|
||||
|
||||
Revision ID: c4_1_access_log_retention
|
||||
Revises: c4_0_grant_expiry
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c4_1_access_log_retention"
|
||||
down_revision = "c4_0_grant_expiry"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_activity_logs_access_timeline",
|
||||
"activity_logs",
|
||||
["tenant_id", "created_at"],
|
||||
postgresql_where=sa.text("module = 'access'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_activity_logs_access_timeline", table_name="activity_logs")
|
||||
@@ -0,0 +1,161 @@
|
||||
"""C5.1 — access groups, and grants that can name one
|
||||
|
||||
Two new tables, and one genuinely risky change: `user_roles.user_id` becomes
|
||||
nullable, so a grant can name a **group** instead.
|
||||
|
||||
That weakens an invariant every existing query relied on. Four readers exist and
|
||||
all four were reviewed before this migration was written:
|
||||
|
||||
* `app/core/scope.py` — resolution; gains a group branch
|
||||
* `app/modules/auth/services/permission_service.py` — the coarse gate; likewise
|
||||
* `app/modules/org/routes/org_routes.py` — lists a user's own grants; unchanged
|
||||
behaviour, it simply will not list grants a user holds *through* a group
|
||||
* `app/modules/org/services/access_maintenance.py` — the expiry sweeper; its
|
||||
audit entry uses the principal, which may now be a group
|
||||
|
||||
The `CHECK` is what keeps the pair honest. "Exactly one of two nullable columns"
|
||||
is the kind of invariant that survives in the schema and rots in code — one new
|
||||
call site that sets both, and the resolver counts the grant twice.
|
||||
|
||||
Existing rows all have `user_id`, so the constraint holds the moment it is
|
||||
added, and nobody's authority changes.
|
||||
|
||||
Revision ID: c5_0_access_groups
|
||||
Revises: c4_1_access_log_retention
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "c5_0_access_groups"
|
||||
down_revision = "c4_1_access_log_retention"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TENANT_TABLES = ["access_groups", "user_access_groups"]
|
||||
POLICY = "tenant_isolation"
|
||||
USING = """
|
||||
coalesce(current_setting('docqube.bypass', true), '') = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"access_groups",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="uq_access_groups_tenant_name"),
|
||||
)
|
||||
op.create_index("ix_access_groups_tenant_id", "access_groups", ["tenant_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_access_groups",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"group_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("access_groups.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "group_id", name="uq_user_access_groups"),
|
||||
)
|
||||
op.create_index("ix_user_access_groups_tenant_id", "user_access_groups", ["tenant_id"])
|
||||
op.create_index("ix_user_access_groups_user_id", "user_access_groups", ["user_id"])
|
||||
op.create_index("ix_user_access_groups_group_id", "user_access_groups", ["group_id"])
|
||||
op.create_index(
|
||||
"ix_user_access_groups_lookup", "user_access_groups", ["user_id", "group_id"]
|
||||
)
|
||||
|
||||
# The risky half.
|
||||
op.add_column(
|
||||
"user_roles",
|
||||
sa.Column(
|
||||
"group_id",
|
||||
UUID(as_uuid=True),
|
||||
# CASCADE: deleting a group takes its grants with it. A grant whose
|
||||
# principal no longer exists is a row nobody can see, revoke or
|
||||
# audit — and it must not keep applying.
|
||||
sa.ForeignKey("access_groups.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=True)
|
||||
op.create_index("ix_user_roles_group_id", "user_roles", ["group_id"])
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_user_roles_exactly_one_principal",
|
||||
"user_roles",
|
||||
"(user_id IS NOT NULL) <> (group_id IS NOT NULL)",
|
||||
)
|
||||
|
||||
# The tenant-wide uniqueness guard was written for user grants. Group grants
|
||||
# need the same protection, and the original index cannot serve both
|
||||
# because `user_id` is now null on half the rows.
|
||||
op.create_index(
|
||||
"uq_user_roles_group_tenant_wide",
|
||||
"user_roles",
|
||||
["group_id", "role_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("org_unit_id IS NULL AND group_id IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_user_roles_group_scoped",
|
||||
"user_roles",
|
||||
["group_id", "role_id", "org_unit_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("group_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
|
||||
# Group grants cannot survive the column being dropped, and they cannot be
|
||||
# converted into user grants either — the whole point is that they name no
|
||||
# single user. Removing them is the only honest reverse.
|
||||
op.execute("DELETE FROM user_roles WHERE group_id IS NOT NULL")
|
||||
|
||||
op.drop_index("uq_user_roles_group_scoped", table_name="user_roles")
|
||||
op.drop_index("uq_user_roles_group_tenant_wide", table_name="user_roles")
|
||||
op.drop_constraint("ck_user_roles_exactly_one_principal", "user_roles", type_="check")
|
||||
op.drop_index("ix_user_roles_group_id", table_name="user_roles")
|
||||
op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_column("user_roles", "group_id")
|
||||
|
||||
op.drop_table("user_access_groups")
|
||||
op.drop_table("access_groups")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""add tenant storage quota bytes
|
||||
|
||||
Revision ID: c7d4e6f1a9b0
|
||||
Revises: e81a9d22ca5f
|
||||
Create Date: 2026-03-07 09:25:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c7d4e6f1a9b0"
|
||||
down_revision: Union[str, None] = "e81a9d22ca5f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tenants",
|
||||
sa.Column("storage_quota_bytes", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# Preserve existing effective quota behavior by summing each tenant's
|
||||
# current per-user quotas. For tenants with no users, default to 1 GB.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tenants t
|
||||
SET storage_quota_bytes = COALESCE(
|
||||
(
|
||||
SELECT SUM(COALESCE(usu.max_bytes_quota, 1073741824))
|
||||
FROM users u
|
||||
LEFT JOIN user_storage_usage usu ON usu.user_id = u.id
|
||||
WHERE u.tenant_id = t.id
|
||||
),
|
||||
1073741824
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"tenants",
|
||||
"storage_quota_bytes",
|
||||
existing_type=sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1073741824"),
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_tenants_storage_quota_bytes_positive",
|
||||
"tenants",
|
||||
"storage_quota_bytes > 0",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"ck_tenants_storage_quota_bytes_positive",
|
||||
"tenants",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("tenants", "storage_quota_bytes")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Merge multiple heads
|
||||
|
||||
Revision ID: ca14c4ab10d9
|
||||
Revises: b0a09cd13348, y001_add_user_mail_from
|
||||
Create Date: 2026-05-08 13:47:42.707120
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ca14c4ab10d9'
|
||||
down_revision: Union[str, None] = ('b0a09cd13348', 'y001_add_user_mail_from')
|
||||
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,83 @@
|
||||
"""Create chat_token_usage table
|
||||
|
||||
Revision ID: cba70ab35793
|
||||
Revises: cd27562afa2e
|
||||
Create Date: 2026-04-16 13:39:46.842540
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cba70ab35793'
|
||||
down_revision: Union[str, None] = 'cd27562afa2e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# (index_name, columns, unique, use_naming_convention)
|
||||
# use_naming_convention=True wraps in batch_op.f(); False uses the raw string
|
||||
# (matches the original auto-generated/hand-written split).
|
||||
_INDEXES = [
|
||||
('ix_chat_token_usage_chat_id', ['chat_id'], False, True),
|
||||
('ix_chat_token_usage_document_id', ['document_id'], False, True),
|
||||
('ix_chat_token_usage_id', ['id'], False, True),
|
||||
('ix_chat_token_usage_model_name', ['model_name'], False, True),
|
||||
('ix_chat_token_usage_model_time', ['model_name', 'timestamp'], False, False),
|
||||
('ix_chat_token_usage_phase', ['phase'], False, True),
|
||||
('ix_chat_token_usage_timestamp', ['timestamp'], False, True),
|
||||
('ix_chat_token_usage_user_id', ['user_id'], False, True),
|
||||
('ix_chat_token_usage_user_time', ['user_id', 'timestamp'], False, False),
|
||||
]
|
||||
|
||||
|
||||
def _index_names(bind, table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(bind)
|
||||
return {idx["name"] for idx in inspector.get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table('chat_token_usage'):
|
||||
op.create_table('chat_token_usage',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('chat_id', sa.Integer(), nullable=True),
|
||||
sa.Column('document_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('prompt_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('completion_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('total_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('model_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('cost_usd', sa.DECIMAL(precision=12, scale=8), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('phase', sa.String(length=50), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
existing = _index_names(bind, 'chat_token_usage')
|
||||
with op.batch_alter_table('chat_token_usage', schema=None) as batch_op:
|
||||
for name, cols, unique, use_f in _INDEXES:
|
||||
if name not in existing:
|
||||
ref = batch_op.f(name) if use_f else name
|
||||
batch_op.create_index(ref, cols, unique=unique)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if inspector.has_table('chat_token_usage'):
|
||||
existing = _index_names(bind, 'chat_token_usage')
|
||||
with op.batch_alter_table('chat_token_usage', schema=None) as batch_op:
|
||||
for name, _, _, use_f in reversed(_INDEXES):
|
||||
if name in existing:
|
||||
ref = batch_op.f(name) if use_f else name
|
||||
batch_op.drop_index(ref)
|
||||
|
||||
op.drop_table('chat_token_usage')
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Add chat token usage tracking
|
||||
|
||||
Revision ID: cd27562afa2e
|
||||
Revises: 1f4d2a8c9b77
|
||||
Create Date: 2026-04-16 13:05:09.572442
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cd27562afa2e'
|
||||
down_revision: Union[str, None] = '1f4d2a8c9b77'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, 'chatbot_documents'):
|
||||
with op.batch_alter_table('chatbot_documents', schema=None) as batch_op:
|
||||
batch_op.alter_column('chunks_count',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=False)
|
||||
|
||||
if _table_exists(bind, 'vector_chunks'):
|
||||
with op.batch_alter_table('vector_chunks', schema=None) as batch_op:
|
||||
batch_op.alter_column('document_id',
|
||||
existing_type=sa.VARCHAR(),
|
||||
nullable=False)
|
||||
batch_op.alter_column('chunk_text',
|
||||
existing_type=sa.VARCHAR(),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('chunk_index',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
nullable=False)
|
||||
batch_op.drop_constraint('vector_chunks_user_id_fkey', type_='foreignkey')
|
||||
batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
if _table_exists(bind, 'vector_indices'):
|
||||
with op.batch_alter_table('vector_indices', schema=None) as batch_op:
|
||||
batch_op.alter_column('total_vectors',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
type_=sa.DateTime(timezone=True),
|
||||
nullable=False)
|
||||
batch_op.drop_constraint('vector_indices_user_id_fkey', type_='foreignkey')
|
||||
batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, 'vector_indices'):
|
||||
with op.batch_alter_table('vector_indices', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.create_foreign_key('vector_indices_user_id_fkey', 'users', ['user_id'], ['id'])
|
||||
batch_op.drop_index(batch_op.f('ix_vector_indices_user_id'))
|
||||
batch_op.create_index('ix_vector_indices_user_id', ['user_id'], unique=True)
|
||||
batch_op.create_index('ix_vector_indices_id', ['id'], unique=False)
|
||||
batch_op.alter_column('updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('total_vectors',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
|
||||
if _table_exists(bind, 'vector_chunks'):
|
||||
with op.batch_alter_table('vector_chunks', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.create_foreign_key('vector_chunks_user_id_fkey', 'users', ['user_id'], ['id'])
|
||||
batch_op.create_index('ix_vector_chunks_id', ['id'], unique=False)
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
type_=postgresql.TIMESTAMP(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('chunk_index',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('chunk_text',
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.VARCHAR(),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('document_id',
|
||||
existing_type=sa.VARCHAR(),
|
||||
nullable=True)
|
||||
|
||||
if _table_exists(bind, 'chatbot_documents'):
|
||||
with op.batch_alter_table('chatbot_documents', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_chatbot_documents_id', ['id'], unique=False)
|
||||
batch_op.create_index('ix_chatbot_documents_drive_file_id', ['drive_file_id'], unique=False)
|
||||
batch_op.alter_column('created_at',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('chunks_count',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add_updated_at_defaults
|
||||
|
||||
Revision ID: cfbee4f91fc2
|
||||
Revises: 49c19df2c1d4
|
||||
Create Date: 2026-02-19 11:25:54.874662
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cfbee4f91fc2'
|
||||
down_revision: Union[str, None] = '49c19df2c1d4'
|
||||
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.alter_column('drive_folders', 'updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text('now()'),
|
||||
existing_nullable=False)
|
||||
op.alter_column('drive_files', 'updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text('now()'),
|
||||
existing_nullable=False)
|
||||
op.alter_column('projects', 'updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text('now()'),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('projects', 'updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=None,
|
||||
existing_nullable=False)
|
||||
op.alter_column('drive_files', 'updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=None,
|
||||
existing_nullable=False)
|
||||
op.alter_column('drive_folders', 'updated_at',
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=None,
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,219 @@
|
||||
"""D1 — plans, limits, subscriptions and overrides
|
||||
|
||||
The landing page advertises four priced plans and the backend had never heard of
|
||||
a plan. Meanwhile `tenants` carried four limit columns set per tenant by hand, so
|
||||
two customers on the same advertised plan could silently have different limits.
|
||||
|
||||
This gives those limits something to be derived from.
|
||||
|
||||
**Behaviour-neutral by construction.** Every tenant's four current values become
|
||||
**overrides**, not plan limits — so whatever they have today is exactly what they
|
||||
keep, whichever plan they are placed on. The plan is then chosen to match, or
|
||||
`custom` when nothing does. Nobody's limits move.
|
||||
|
||||
The `tenants` columns are **not** dropped. They stay for one release and remain
|
||||
the last-resort fallback in `EntitlementService`, the same transition shape as
|
||||
`users.role_id` and for the same reason: a rollback must not be able to remove
|
||||
every tenant's quota.
|
||||
|
||||
`plans` and `plan_limits` are deliberately **not** tenant-scoped — a catalogue is
|
||||
shared, like `accesses`. `tenant_subscriptions` and `tenant_limit_overrides` are,
|
||||
and are added to the RLS policy here.
|
||||
|
||||
Revision ID: d1_0_plans_and_entitlements
|
||||
Revises: c3_0_org_unit_path
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "d1_0_plans_and_entitlements"
|
||||
down_revision = "c3_0_org_unit_path"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TENANT_TABLES = ["tenant_subscriptions", "tenant_limit_overrides"]
|
||||
POLICY = "tenant_isolation"
|
||||
USING = """
|
||||
coalesce(current_setting('docqube.bypass', true), '') = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid
|
||||
"""
|
||||
|
||||
# The four plans the pricing page already sells, with the limits it claims.
|
||||
# Seeded here rather than in `scripts/seed.py` so that a database which has run
|
||||
# migrations is one a customer could be placed on — the page and the schema
|
||||
# start out agreeing.
|
||||
#
|
||||
# -1 means unlimited, matching the convention `tenants.envelope_limit` already
|
||||
# uses.
|
||||
GB = 1024 ** 3
|
||||
PLANS = [
|
||||
# code, name, price, public, order
|
||||
("starter", "Starter plan", "$12", True, 1),
|
||||
("professional", "Professional plan", "$22", True, 2),
|
||||
("elite", "Elite plan", "$29", True, 3),
|
||||
("custom", "Custom plan", "Contact Us", True, 4),
|
||||
]
|
||||
LIMITS = {
|
||||
"starter": {"storage_bytes": 10 * GB, "seats": 5,
|
||||
"chat_tokens_daily": 500_000, "envelopes": 25},
|
||||
"professional": {"storage_bytes": 100 * GB, "seats": 25,
|
||||
"chat_tokens_daily": 2_000_000, "envelopes": 250},
|
||||
"elite": {"storage_bytes": 500 * GB, "seats": 100,
|
||||
"chat_tokens_daily": 10_000_000, "envelopes": -1},
|
||||
"custom": {"storage_bytes": -1, "seats": -1,
|
||||
"chat_tokens_daily": -1, "envelopes": -1},
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"plans",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("code", sa.String(50), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("price_display", sa.String(50), nullable=True),
|
||||
sa.Column("currency", sa.String(10), nullable=True),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_plans_code", "plans", ["code"])
|
||||
|
||||
op.create_table(
|
||||
"plan_limits",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("plan_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("plans.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("key", sa.String(50), nullable=False),
|
||||
sa.Column("value", sa.BigInteger(), nullable=False),
|
||||
sa.UniqueConstraint("plan_id", "key", name="uq_plan_limits_plan_key"),
|
||||
)
|
||||
op.create_index("ix_plan_limits_plan_id", "plan_limits", ["plan_id"])
|
||||
|
||||
op.create_table(
|
||||
"tenant_subscriptions",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
# RESTRICT: deleting a plan somebody is on would silently drop them to
|
||||
# the defaults. Move them first, deliberately.
|
||||
sa.Column("plan_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancel_at_period_end", sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()),
|
||||
sa.Column("external_ref", sa.String(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False),
|
||||
# One live subscription per tenant: a second "current" row is an
|
||||
# ambiguity nothing can resolve.
|
||||
sa.UniqueConstraint("tenant_id", name="uq_tenant_subscriptions_tenant"),
|
||||
)
|
||||
op.create_index("ix_tenant_subscriptions_tenant_id", "tenant_subscriptions", ["tenant_id"])
|
||||
op.create_index("ix_tenant_subscriptions_plan_id", "tenant_subscriptions", ["plan_id"])
|
||||
op.create_index("ix_tenant_subscriptions_status", "tenant_subscriptions", ["status"])
|
||||
op.create_index("ix_tenant_subscriptions_external_ref", "tenant_subscriptions", ["external_ref"])
|
||||
|
||||
op.create_table(
|
||||
"tenant_limit_overrides",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("key", sa.String(50), nullable=False),
|
||||
sa.Column("value", sa.BigInteger(), nullable=False),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "key", name="uq_tenant_limit_overrides"),
|
||||
)
|
||||
op.create_index("ix_tenant_limit_overrides_tenant_id", "tenant_limit_overrides", ["tenant_id"])
|
||||
|
||||
# --- seed the catalogue -------------------------------------------------
|
||||
for code, name, price, public, order in PLANS:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plans (id, code, name, price_display, currency, "
|
||||
"is_public, sort_order, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid(), :code, :name, :price, 'USD', "
|
||||
":public, :order, now(), now())"
|
||||
).bindparams(code=code, name=name, price=price, public=public, order=order)
|
||||
)
|
||||
for key, value in LIMITS[code].items():
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_limits (id, plan_id, key, value) "
|
||||
"SELECT gen_random_uuid(), id, :key, :value FROM plans "
|
||||
" WHERE code = :code"
|
||||
).bindparams(key=key, value=value, code=code)
|
||||
)
|
||||
|
||||
# --- backfill, behaviour-neutral ----------------------------------------
|
||||
#
|
||||
# Current values become **overrides**, so every tenant keeps exactly what it
|
||||
# has regardless of which plan it lands on. The plan is a label until
|
||||
# somebody removes the override.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_limit_overrides (id, tenant_id, key, value, note, created_at)
|
||||
SELECT gen_random_uuid(), t.id, v.key, v.value,
|
||||
'Carried over from the tenant row at D1; not a negotiated exception',
|
||||
now()
|
||||
FROM tenants t
|
||||
CROSS JOIN LATERAL (VALUES
|
||||
('storage_bytes', t.storage_quota_bytes),
|
||||
('seats', COALESCE(t.max_users_allowed, -1)),
|
||||
('chat_tokens_daily', t.chat_token_daily_limit),
|
||||
('envelopes', t.envelope_limit)
|
||||
) AS v(key, value)
|
||||
WHERE t.is_deleted = false
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
# Every live tenant is placed on `custom`, not on a guessed plan.
|
||||
#
|
||||
# Matching limits to a plan would be inference, and inferring a commercial
|
||||
# fact is how somebody ends up on Starter because their quota happened to
|
||||
# look like it. `custom` is honest: nobody has said what they are on yet,
|
||||
# and their overrides mean it changes nothing.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_subscriptions (id, tenant_id, plan_id, status,
|
||||
started_at, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), t.id, p.id, 'active', now(), now(), now()
|
||||
FROM tenants t
|
||||
CROSS JOIN (SELECT id FROM plans WHERE code = 'custom') p
|
||||
WHERE t.is_deleted = false
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
|
||||
|
||||
# Safe to reverse: the `tenants` columns were never touched, so removing
|
||||
# these tables returns the application to the limits it still reads.
|
||||
op.drop_table("tenant_limit_overrides")
|
||||
op.drop_table("tenant_subscriptions")
|
||||
op.drop_table("plan_limits")
|
||||
op.drop_table("plans")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""D2 — a credit limit stops being rounded to a whole number
|
||||
|
||||
`chat_credit_quota.daily_credit_limit` is `bigint` in the database and
|
||||
`DECIMAL(12, 2)` on the model. PostgreSQL resolves that disagreement by
|
||||
rounding, silently, on write:
|
||||
|
||||
INSERT ... VALUES (0.60::numeric(12,2)) -> stored: 1
|
||||
INSERT ... VALUES (10.75::numeric(12,2)) -> stored: 11
|
||||
INSERT ... VALUES (10.25::numeric(12,2)) -> stored: 10
|
||||
|
||||
So a tenant admin setting a limit of 0.60 credits gets 1 — a 67% overshoot — and
|
||||
nothing anywhere reports it. Whether that has bitten depends on whether anyone
|
||||
has entered a fraction, which is not a property worth relying on.
|
||||
|
||||
Widening, so no value is at risk: every bigint currently stored is representable
|
||||
in `numeric(12, 2)`. `credits_used_today` and `total_credits_ever` are left as
|
||||
`bigint` deliberately — they count tokens, which are whole.
|
||||
|
||||
The reverse casts back to `bigint` and therefore *does* round. That is the
|
||||
honest reverse of a widening: going back to a narrower type cannot preserve what
|
||||
the wider one held. It is written that way rather than left unimplemented so the
|
||||
migration can actually be reversed, but rolling this back after fractional
|
||||
limits have been set will round them.
|
||||
|
||||
Found by `alembic revision --autogenerate`, which had never been run against
|
||||
this schema, and which after the model-registry fix reports this as the single
|
||||
remaining difference between the models and the database.
|
||||
|
||||
Revision ID: d2_0_credit_limit_precision
|
||||
Revises: d1_0_plans_and_entitlements
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d2_0_credit_limit_precision"
|
||||
down_revision = "d1_0_plans_and_entitlements"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"chat_credit_quota",
|
||||
"daily_credit_limit",
|
||||
existing_type=sa.BigInteger(),
|
||||
type_=sa.DECIMAL(precision=12, scale=2),
|
||||
existing_nullable=False,
|
||||
postgresql_using="daily_credit_limit::numeric(12,2)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"chat_credit_quota",
|
||||
"daily_credit_limit",
|
||||
existing_type=sa.DECIMAL(precision=12, scale=2),
|
||||
type_=sa.BigInteger(),
|
||||
existing_nullable=False,
|
||||
postgresql_using="round(daily_credit_limit)::bigint",
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Add 10MB CHECK constraints on document content fields
|
||||
|
||||
Revision ID: d2e2c55290ee
|
||||
Revises: 9f6521a38bf3
|
||||
Create Date: 2026-03-03 22:17:00.564459
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd2e2c55290ee'
|
||||
down_revision: Union[str, None] = '9f6521a38bf3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# 10 MB limit in bytes
|
||||
_MAX = 10 * 1024 * 1024 # 10485760
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# projects table — three content columns
|
||||
op.create_check_constraint(
|
||||
'ck_project_markdown_max', 'projects',
|
||||
f'octet_length(markdown_content) <= {_MAX}'
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_project_xml_max', 'projects',
|
||||
f'octet_length(xml_content) <= {_MAX}'
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_project_html_max', 'projects',
|
||||
f'octet_length(html_content) <= {_MAX}'
|
||||
)
|
||||
|
||||
# project_versions table — three content columns
|
||||
op.create_check_constraint(
|
||||
'ck_version_markdown_max', 'project_versions',
|
||||
f'octet_length(markdown_content) <= {_MAX}'
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_version_xml_max', 'project_versions',
|
||||
f'octet_length(xml_content) <= {_MAX}'
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_version_html_max', 'project_versions',
|
||||
f'octet_length(html_content) <= {_MAX}'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('ck_version_html_max', 'project_versions', type_='check')
|
||||
op.drop_constraint('ck_version_xml_max', 'project_versions', type_='check')
|
||||
op.drop_constraint('ck_version_markdown_max', 'project_versions', type_='check')
|
||||
op.drop_constraint('ck_project_html_max', 'projects', type_='check')
|
||||
op.drop_constraint('ck_project_xml_max', 'projects', type_='check')
|
||||
op.drop_constraint('ck_project_markdown_max', 'projects', type_='check')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""rename_quota_to_credits
|
||||
|
||||
Revision ID: d551c03861f4
|
||||
Revises: 540c970ff890
|
||||
Create Date: 2026-05-13 13:01:51.510123
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd551c03861f4'
|
||||
down_revision: Union[str, None] = '540c970ff890'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Rename Table
|
||||
op.rename_table('chat_token_quota', 'chat_credit_quota')
|
||||
|
||||
# Rename Columns in chat_credit_quota
|
||||
op.alter_column('chat_credit_quota', 'daily_limit', new_column_name='daily_credit_limit')
|
||||
op.alter_column('chat_credit_quota', 'tokens_used_today', new_column_name='credits_used_today')
|
||||
op.alter_column('chat_credit_quota', 'total_tokens_ever', new_column_name='total_credits_ever')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Rename Columns back
|
||||
op.alter_column('chat_credit_quota', 'daily_credit_limit', new_column_name='daily_limit')
|
||||
op.alter_column('chat_credit_quota', 'credits_used_today', new_column_name='tokens_used_today')
|
||||
op.alter_column('chat_credit_quota', 'total_credits_ever', new_column_name='total_tokens_ever')
|
||||
|
||||
# Rename Table back
|
||||
op.rename_table('chat_credit_quota', 'chat_token_quota')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add envelope limits to tenants
|
||||
|
||||
Revision ID: daf45366d608
|
||||
Revises: 611d6b99ad11
|
||||
Create Date: 2026-07-18 10:34:04.536445
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'daf45366d608'
|
||||
down_revision: Union[str, None] = '611d6b99ad11'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tenants', sa.Column('envelope_limit', sa.BigInteger(), nullable=False, server_default='-1'))
|
||||
op.add_column('tenants', sa.Column('envelopes_used', sa.BigInteger(), nullable=False, server_default='0'))
|
||||
op.add_column('tenants', sa.Column('envelope_reset_days', sa.Integer(), nullable=True))
|
||||
op.add_column('tenants', sa.Column('envelope_last_reset_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tenants', 'envelope_last_reset_at')
|
||||
op.drop_column('tenants', 'envelope_reset_days')
|
||||
op.drop_column('tenants', 'envelopes_used')
|
||||
op.drop_column('tenants', 'envelope_limit')
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add color to drive comments
|
||||
|
||||
Revision ID: dbaa8ef522c0
|
||||
Revises: 8736ab6bd5af
|
||||
Create Date: 2026-02-26 14:23:14.999888
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'dbaa8ef522c0'
|
||||
down_revision: Union[str, None] = '8736ab6bd5af'
|
||||
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.add_column('drive_comments', sa.Column('color', sa.String(length=20), nullable=True))
|
||||
op.alter_column('notifications', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('notifications', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_column('drive_comments', 'color')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,28 @@
|
||||
"""device constraints and session updates
|
||||
|
||||
Revision ID: dfae38e4ede3
|
||||
Revises: 9c49fe120ffb
|
||||
Create Date: 2026-08-07 11:03:54.251473
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'dfae38e4ede3'
|
||||
down_revision: Union[str, None] = '9c49fe120ffb'
|
||||
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_unique_constraint('uq_device_user_fingerprint', 'devices', ['user_id', 'fingerprint'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('uq_device_user_fingerprint', 'devices', type_='unique')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add saas_plan_id to plans
|
||||
|
||||
Revision ID: e1_0_add_saas_plan_id
|
||||
Revises: 9c4a95ac6726
|
||||
Create Date: 2026-09-01
|
||||
|
||||
Adds a nullable `saas_plan_id` column to the `plans` table so that DocQube
|
||||
can trace each plan back to the SaaS SubscriptionPlan that originated it via
|
||||
the provision webhook. Plans created directly inside DocQube will have NULL.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e1_0_add_saas_plan_id"
|
||||
down_revision = "9c4a95ac6726"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"plans",
|
||||
sa.Column("saas_plan_id", sa.String(255), nullable=True),
|
||||
)
|
||||
op.create_index("ix_plans_saas_plan_id", "plans", ["saas_plan_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_plans_saas_plan_id", table_name="plans")
|
||||
op.drop_column("plans", "saas_plan_id")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""migrate_roles_access_to_uuid
|
||||
|
||||
Revision ID: e81a9d22ca5f
|
||||
Revises: a2b3c4d5e6f7
|
||||
Create Date: 2026-03-05 14:42:53.769667
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e81a9d22ca5f'
|
||||
down_revision: Union[str, None] = 'a2b3c4d5e6f7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- Users Table ---
|
||||
# Drop FK pointing to roles
|
||||
op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey')
|
||||
|
||||
# --- RoleAccess Table ---
|
||||
# Drop FKs
|
||||
op.drop_constraint('role_accesses_role_id_fkey', 'role_accesses', type_='foreignkey')
|
||||
op.drop_constraint('role_accesses_access_id_fkey', 'role_accesses', type_='foreignkey')
|
||||
op.drop_constraint('pk_role_accesses', 'role_accesses', type_='primary')
|
||||
|
||||
# --- Accesses Table ---
|
||||
# FK was named 'fk_accesses_parent_id' in migration a2b3c4d5e6f7
|
||||
op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey')
|
||||
|
||||
# --- Alter Columns to UUID ---
|
||||
# Drop serial defaults before changing type
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN id DROP DEFAULT')
|
||||
op.execute('ALTER TABLE roles ALTER COLUMN id DROP DEFAULT')
|
||||
|
||||
# Change types to UUID (tables should be empty)
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN id TYPE uuid USING (gen_random_uuid())')
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN parent_id TYPE uuid USING NULL')
|
||||
|
||||
op.execute('ALTER TABLE roles ALTER COLUMN id TYPE uuid USING (gen_random_uuid())')
|
||||
|
||||
op.execute('ALTER TABLE role_accesses ALTER COLUMN role_id TYPE uuid USING (gen_random_uuid())')
|
||||
op.execute('ALTER TABLE role_accesses ALTER COLUMN access_id TYPE uuid USING (gen_random_uuid())')
|
||||
|
||||
op.execute('ALTER TABLE users ALTER COLUMN role_id TYPE uuid USING NULL')
|
||||
|
||||
# Set UUID defaults for primary keys
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN id SET DEFAULT gen_random_uuid()')
|
||||
op.execute('ALTER TABLE roles ALTER COLUMN id SET DEFAULT gen_random_uuid()')
|
||||
|
||||
# Drop leftover serial sequences
|
||||
op.execute('DROP SEQUENCE IF EXISTS accesses_id_seq CASCADE')
|
||||
op.execute('DROP SEQUENCE IF EXISTS roles_id_seq CASCADE')
|
||||
|
||||
# --- Re-add Constraints ---
|
||||
# Accesses
|
||||
op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
# RoleAccess
|
||||
op.create_primary_key('pk_role_accesses', 'role_accesses', ['role_id', 'access_id'])
|
||||
op.create_foreign_key('role_accesses_role_id_fkey', 'role_accesses', 'roles', ['role_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('role_accesses_access_id_fkey', 'role_accesses', 'accesses', ['access_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
# Users
|
||||
op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# --- Users Table ---
|
||||
op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey')
|
||||
|
||||
# --- RoleAccess Table ---
|
||||
op.drop_constraint('role_accesses_role_id_fkey', 'role_accesses', type_='foreignkey')
|
||||
op.drop_constraint('role_accesses_access_id_fkey', 'role_accesses', type_='foreignkey')
|
||||
op.drop_constraint('pk_role_accesses', 'role_accesses', type_='primary')
|
||||
|
||||
# --- Accesses Table ---
|
||||
op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey')
|
||||
|
||||
# Truncate all tables (UUID -> INT cast is destructive)
|
||||
op.execute('TRUNCATE TABLE role_accesses CASCADE')
|
||||
op.execute('TRUNCATE TABLE users CASCADE')
|
||||
op.execute('TRUNCATE TABLE accesses CASCADE')
|
||||
op.execute('TRUNCATE TABLE roles CASCADE')
|
||||
|
||||
# Drop UUID defaults
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN id DROP DEFAULT')
|
||||
op.execute('ALTER TABLE roles ALTER COLUMN id DROP DEFAULT')
|
||||
|
||||
# Change types back to integer
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN id TYPE integer USING 0')
|
||||
op.execute('ALTER TABLE accesses ALTER COLUMN parent_id TYPE integer USING NULL')
|
||||
op.execute('ALTER TABLE roles ALTER COLUMN id TYPE integer USING 0')
|
||||
op.execute('ALTER TABLE role_accesses ALTER COLUMN role_id TYPE integer USING 0')
|
||||
op.execute('ALTER TABLE role_accesses ALTER COLUMN access_id TYPE integer USING 0')
|
||||
op.execute('ALTER TABLE users ALTER COLUMN role_id TYPE integer USING NULL')
|
||||
|
||||
# Recreate serial sequences
|
||||
op.execute("CREATE SEQUENCE accesses_id_seq OWNED BY accesses.id")
|
||||
op.execute("ALTER TABLE accesses ALTER COLUMN id SET DEFAULT nextval('accesses_id_seq')")
|
||||
op.execute("CREATE SEQUENCE roles_id_seq OWNED BY roles.id")
|
||||
op.execute("ALTER TABLE roles ALTER COLUMN id SET DEFAULT nextval('roles_id_seq')")
|
||||
|
||||
# --- Re-add Constraints ---
|
||||
op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
op.create_primary_key('pk_role_accesses', 'role_accesses', ['role_id', 'access_id'])
|
||||
op.create_foreign_key('role_accesses_role_id_fkey', 'role_accesses', 'roles', ['role_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('role_accesses_access_id_fkey', 'role_accesses', 'accesses', ['access_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'], ondelete='SET NULL')
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add cascade deletes to drive_file_versions
|
||||
|
||||
Revision ID: f055f9b157cb
|
||||
Revises: a902935ea69e
|
||||
Create Date: 2026-03-02 11:08:38.477933
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f055f9b157cb'
|
||||
down_revision: Union[str, None] = 'a902935ea69e'
|
||||
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.drop_constraint('drive_file_versions_file_id_fkey', 'drive_file_versions', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_file_versions', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_constraint('drive_stars_user_id_fkey', 'drive_stars', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'drive_stars', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'drive_stars', type_='foreignkey')
|
||||
op.create_foreign_key('drive_stars_user_id_fkey', 'drive_stars', 'users', ['user_id'], ['id'])
|
||||
op.drop_constraint(None, 'drive_file_versions', type_='foreignkey')
|
||||
op.create_foreign_key('drive_file_versions_file_id_fkey', 'drive_file_versions', 'drive_files', ['file_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add has_completed_tutorial to User
|
||||
|
||||
Revision ID: fb6810c36ffb
|
||||
Revises: 229359b0d1b2
|
||||
Create Date: 2026-04-02 11:27:02.719590
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'fb6810c36ffb'
|
||||
down_revision: Union[str, None] = '229359b0d1b2'
|
||||
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! ###
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('has_completed_tutorial', sa.Boolean(), server_default=sa.text('false'), nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('has_completed_tutorial')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add ip_address to drive_activities
|
||||
|
||||
Revision ID: ff8052a97834
|
||||
Revises: 765b053e3dcd
|
||||
Create Date: 2026-03-03 21:48:41.907463
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ff8052a97834'
|
||||
down_revision: Union[str, None] = '765b053e3dcd'
|
||||
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.add_column('drive_activities', sa.Column('ip_address', sa.String(length=64), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('drive_activities', 'ip_address')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Add multi_tenancy
|
||||
|
||||
Revision ID: ff9052a97835
|
||||
Revises: ff8052a97834
|
||||
Create Date: 2026-03-05 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ff9052a97835"
|
||||
down_revision: Union[str, None] = "ff8052a97834"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create tenants table
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# 3. Add tenant_id columns (nullable first)
|
||||
tables = [
|
||||
"users",
|
||||
"projects",
|
||||
"drive_folders",
|
||||
"drive_files",
|
||||
"drive_activities",
|
||||
"chatbot_documents",
|
||||
"notifications",
|
||||
"user_files",
|
||||
]
|
||||
for table in tables:
|
||||
op.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN tenant_id UUID REFERENCES tenants(id);"
|
||||
)
|
||||
|
||||
# 4. Backfill existing data
|
||||
for table in tables:
|
||||
op.execute(
|
||||
f"UPDATE {table} SET tenant_id = '00000000-0000-0000-0000-000000000001';"
|
||||
)
|
||||
|
||||
# 5. Set NOT NULL (except users)
|
||||
not_null_tables = [
|
||||
"projects",
|
||||
"drive_folders",
|
||||
"drive_files",
|
||||
"drive_activities",
|
||||
"chatbot_documents",
|
||||
"notifications",
|
||||
"user_files",
|
||||
]
|
||||
for table in not_null_tables:
|
||||
op.execute(f"ALTER TABLE {table} ALTER COLUMN tenant_id SET NOT NULL;")
|
||||
|
||||
# 6. Create indexes
|
||||
for table in tables:
|
||||
op.execute(f"CREATE INDEX ix_{table}_tenant_id ON {table}(tenant_id);")
|
||||
|
||||
# 7. Constraint unique (tenant_id, email)
|
||||
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;")
|
||||
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_email;")
|
||||
op.execute(
|
||||
"ALTER TABLE users ADD CONSTRAINT uq_user_tenant_email UNIQUE (tenant_id, email);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_user_tenant_email;")
|
||||
op.execute("ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);")
|
||||
|
||||
tables = [
|
||||
"users",
|
||||
"projects",
|
||||
"drive_folders",
|
||||
"drive_files",
|
||||
"drive_activities",
|
||||
"chatbot_documents",
|
||||
"notifications",
|
||||
"user_files",
|
||||
]
|
||||
for table in tables:
|
||||
op.execute(f"DROP INDEX IF EXISTS ix_{table}_tenant_id;")
|
||||
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS tenant_id;")
|
||||
|
||||
op.execute("DROP TABLE IF EXISTS tenants;")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add signing_requests table
|
||||
|
||||
Revision ID: g001_signing
|
||||
Revises: c7d4e6f1a9b0
|
||||
Create Date: 2026-03-06
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSON
|
||||
|
||||
# revision identifiers
|
||||
revision = "g001_signing"
|
||||
down_revision = "c7d4e6f1a9b0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"signing_requests",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id"), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("drive_file_id", sa.Integer, sa.ForeignKey("drive_files.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("docuseal_template_id", sa.Integer, nullable=True),
|
||||
sa.Column("docuseal_submission_id", sa.Integer, nullable=True),
|
||||
sa.Column("docuseal_slug", sa.String(255), nullable=True),
|
||||
sa.Column("signing_type", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("submitters_json", JSON, nullable=True),
|
||||
sa.Column("signed_file_id", sa.Integer, sa.ForeignKey("drive_files.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("signing_requests")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""replace docuseal columns with placements_json
|
||||
|
||||
Revision ID: g002_placements
|
||||
Revises: g001_signing
|
||||
Create Date: 2026-03-07
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
# revision identifiers
|
||||
revision = "g002_placements"
|
||||
down_revision = "g001_signing"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.drop_column("signing_requests", "docuseal_template_id")
|
||||
op.drop_column("signing_requests", "docuseal_submission_id")
|
||||
op.drop_column("signing_requests", "docuseal_slug")
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("placements_json", JSON, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("signing_requests", "placements_json")
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("docuseal_template_id", sa.Integer, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("docuseal_submission_id", sa.Integer, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("docuseal_slug", sa.String(255), nullable=True),
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""add tenant smtp settings
|
||||
|
||||
Revision ID: h003_tenant_smtp
|
||||
Revises: g002_placements
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "h003_tenant_smtp"
|
||||
down_revision: Union[str, None] = "g002_placements"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table("tenant_smtp_configs"):
|
||||
op.create_table(
|
||||
"tenant_smtp_configs",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("smtp_host", sa.String(length=255), nullable=False),
|
||||
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default=sa.text("587")),
|
||||
sa.Column("smtp_user", sa.String(length=255), nullable=True),
|
||||
sa.Column("encrypted_smtp_password", sa.String(length=512), nullable=True),
|
||||
sa.Column("smtp_secure", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("mail_from", sa.String(length=255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_by_id", sa.Integer(), nullable=True),
|
||||
sa.Column("updated_by_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", name="uq_tenant_smtp_configs_tenant_id"),
|
||||
)
|
||||
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("tenant_smtp_configs")}
|
||||
if "ix_tenant_smtp_configs_tenant_id" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_tenant_smtp_configs_tenant_id",
|
||||
"tenant_smtp_configs",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_tenant_smtp_configs_tenant_id", table_name="tenant_smtp_configs")
|
||||
op.drop_table("tenant_smtp_configs")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add forensic metadata columns to signing_requests
|
||||
|
||||
Revision ID: h004_signing_forensic_v2
|
||||
Revises: h004_limits_soft_delete
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "h004_signing_forensic_v2"
|
||||
down_revision: Union[str, None] = "h004_limits_soft_delete"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Safe: only add if column doesn't already exist (idempotent for existing DBs)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing = {c["name"] for c in inspector.get_columns("signing_requests")}
|
||||
|
||||
if "ip_address" not in existing:
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("ip_address", sa.String(64), nullable=True),
|
||||
)
|
||||
if "user_agent" not in existing:
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||
)
|
||||
if "signing_metadata" not in existing:
|
||||
op.add_column(
|
||||
"signing_requests",
|
||||
sa.Column("signing_metadata", JSON, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("signing_requests", "signing_metadata")
|
||||
op.drop_column("signing_requests", "user_agent")
|
||||
op.drop_column("signing_requests", "ip_address")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""add limits config and soft delete columns
|
||||
|
||||
Revision ID: h004_limits_soft_delete
|
||||
Revises: h003_tenant_smtp
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "h004_limits_soft_delete"
|
||||
down_revision: Union[str, None] = "h003_tenant_smtp"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true"))
|
||||
)
|
||||
op.add_column(
|
||||
"users", sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false"))
|
||||
)
|
||||
op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("users", sa.Column("deleted_by_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_users_deleted_by_id_users",
|
||||
"users",
|
||||
"users",
|
||||
["deleted_by_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"tenants", sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false"))
|
||||
)
|
||||
op.add_column("tenants", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("tenants", sa.Column("deleted_by_id", sa.Integer(), nullable=True))
|
||||
op.add_column("tenants", sa.Column("max_users_allowed", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_tenants_deleted_by_id_users",
|
||||
"tenants",
|
||||
"users",
|
||||
["deleted_by_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"system_configurations",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("config_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("int_value", sa.BigInteger(), nullable=True),
|
||||
sa.Column("bool_value", sa.Boolean(), nullable=True),
|
||||
sa.Column("text_value", sa.Text(), nullable=True),
|
||||
sa.Column("created_by_id", sa.Integer(), nullable=True),
|
||||
sa.Column("updated_by_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("config_key", name="uq_system_configurations_config_key"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_system_configurations_config_key",
|
||||
"system_configurations",
|
||||
["config_key"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_system_configurations_config_key", table_name="system_configurations")
|
||||
op.drop_table("system_configurations")
|
||||
|
||||
op.drop_constraint("fk_tenants_deleted_by_id_users", "tenants", type_="foreignkey")
|
||||
op.drop_column("tenants", "max_users_allowed")
|
||||
op.drop_column("tenants", "deleted_by_id")
|
||||
op.drop_column("tenants", "deleted_at")
|
||||
op.drop_column("tenants", "is_deleted")
|
||||
|
||||
op.drop_constraint("fk_users_deleted_by_id_users", "users", type_="foreignkey")
|
||||
op.drop_column("users", "deleted_by_id")
|
||||
op.drop_column("users", "deleted_at")
|
||||
op.drop_column("users", "is_deleted")
|
||||
op.drop_column("users", "is_active")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Replace b2_bucket_name with b2_quarantine_bucket and b2_clean_bucket
|
||||
|
||||
Revision ID: i001_dual_bucket_storage
|
||||
Revises: 9557c7307575
|
||||
Create Date: 2026-03-17 08:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'i001_dual_bucket_storage'
|
||||
down_revision = '9557c7307575'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add the two new bucket columns (nullable at first)
|
||||
op.add_column(
|
||||
'tenant_storage_configs',
|
||||
sa.Column('b2_quarantine_bucket', sa.String(255), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
'tenant_storage_configs',
|
||||
sa.Column('b2_clean_bucket', sa.String(255), nullable=True)
|
||||
)
|
||||
|
||||
# 2. Migrate existing b2_bucket_name to quarantine (best-effort)
|
||||
op.execute("""
|
||||
UPDATE tenant_storage_configs
|
||||
SET b2_quarantine_bucket = b2_bucket_name
|
||||
WHERE b2_bucket_name IS NOT NULL
|
||||
""")
|
||||
|
||||
# 3. Drop the old column
|
||||
op.drop_column('tenant_storage_configs', 'b2_bucket_name')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Restore the old column
|
||||
op.add_column(
|
||||
'tenant_storage_configs',
|
||||
sa.Column('b2_bucket_name', sa.String(255), nullable=True)
|
||||
)
|
||||
|
||||
# 2. Restore data from quarantine bucket (best-effort)
|
||||
op.execute("""
|
||||
UPDATE tenant_storage_configs
|
||||
SET b2_bucket_name = b2_quarantine_bucket
|
||||
WHERE b2_quarantine_bucket IS NOT NULL
|
||||
""")
|
||||
|
||||
# 3. Drop the new columns
|
||||
op.drop_column('tenant_storage_configs', 'b2_clean_bucket')
|
||||
op.drop_column('tenant_storage_configs', 'b2_quarantine_bucket')
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add rejected_at column to drive_comments
|
||||
|
||||
Revision ID: j001_comments_fix
|
||||
Revises: c3abf44266de
|
||||
Create Date: 2026-03-24
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "j001_comments_fix"
|
||||
down_revision: Union[str, tuple, None] = "c3abf44266de"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE drive_comments
|
||||
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE drive_comments
|
||||
DROP COLUMN IF EXISTS rejected_at
|
||||
""")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""allow unlimited tenant storage quota sentinel
|
||||
|
||||
Revision ID: j002_unlimited_tenant_quota
|
||||
Revises: j001_comments_fix
|
||||
Create Date: 2026-03-25
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "j002_unlimited_tenant_quota"
|
||||
down_revision: Union[str, None] = "j001_comments_fix"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
CONSTRAINT_NAME = "ck_tenants_storage_quota_bytes_positive"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint(CONSTRAINT_NAME, "tenants", type_="check")
|
||||
op.create_check_constraint(
|
||||
CONSTRAINT_NAME,
|
||||
"tenants",
|
||||
"storage_quota_bytes = -1 OR storage_quota_bytes > 0",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(CONSTRAINT_NAME, "tenants", type_="check")
|
||||
op.create_check_constraint(
|
||||
CONSTRAINT_NAME,
|
||||
"tenants",
|
||||
"storage_quota_bytes > 0",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""limit signature_id to 15 characters
|
||||
|
||||
Revision ID: k001_limit_signature_id_15
|
||||
Revises: 56d090047e85
|
||||
Create Date: 2026-04-15
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "k001_limit_signature_id_15"
|
||||
down_revision: Union[str, None] = "b029d2f5a0c7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Truncate any existing values that exceed 15 chars before altering the column
|
||||
op.execute(
|
||||
"UPDATE signing_requests SET signature_id = LEFT(signature_id, 15) WHERE LENGTH(signature_id) > 15"
|
||||
)
|
||||
op.alter_column(
|
||||
"signing_requests",
|
||||
"signature_id",
|
||||
existing_type=sa.String(128),
|
||||
type_=sa.String(15),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"signing_requests",
|
||||
"signature_id",
|
||||
existing_type=sa.String(15),
|
||||
type_=sa.String(128),
|
||||
existing_nullable=True,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user