Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.

Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-21 21:43:33 +03:30
commit 800b0ba2c5
272 changed files with 21018 additions and 0 deletions

104
.env.example Normal file
View File

@ -0,0 +1,104 @@
# ============================================================
# SuperApp SaaS Platform - Environment Configuration Example
# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید.
# ============================================================
# ---- General ----
ENVIRONMENT=development # development | staging | production
DEBUG=true
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
SERVICE_NAME=core-service
API_V1_PREFIX=/api/v1
# ---- Platform Branding (White-label) ----
# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید.
PLATFORM_NAME=Torbatyar
PLATFORM_SUPPORT_EMAIL=support@example.com
PLATFORM_PRIMARY_COLOR=#0284c7
PLATFORM_SECONDARY_COLOR=#0f172a
# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً <slug>.torbatyar.xyz)
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir)
PLATFORM_BASE_DOMAIN=torbatyar.ir
# ---- Core Database (core_platform_db) ----
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=superapp
POSTGRES_PASSWORD=superapp_password
POSTGRES_DB=core_platform_db
# async driver برای FastAPI/SQLAlchemy
DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db
# sync driver برای Alembic
DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db
# ---- Redis ----
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
REDIS_URL=redis://redis:6379/0
FEATURE_ACCESS_CACHE_TTL=300 # ثانیه
TENANT_METADATA_CACHE_TTL=300
# ---- Celery ----
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2
# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ----
# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156
# Dev محلی: می‌توانید hosts را به 127.0.0.1 اشاره دهید.
KEYCLOAK_ENABLED=true
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir
KEYCLOAK_HOSTNAME=auth.torbatyar.ir
KEYCLOAK_HOSTNAME_STRICT_HTTPS=false
KEYCLOAK_REALM=superapp
KEYCLOAK_CLIENT_ID=core-service
KEYCLOAK_CLIENT_SECRET=change-me
JWT_ALGORITHM=RS256
JWT_AUDIENCE=account
JWT_VERIFY_SIGNATURE=true
# در production و Docker باید true باشد؛ در تست‌ها false
AUTH_REQUIRED=true
# ---- Identity & Access Service ----
IDENTITY_SERVICE_URL=http://identity-access-service:8001
IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db
IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db
KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend
KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity
IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service
FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback
CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000
CORE_SERVICE_URL=http://core-service:8000
# ---- OTP / Payamak SMS (Core Service) ----
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک)
PAYAMAK_USERNAME=9155105404
PAYAMAK_FROM=9982004961
PAYAMAK_FROM_NUMBER=9982004961
# ApiKey را در پارامتر password ارسال کنید (مستندات ملی‌پیامک)
PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f
PAYAMAK_BODY_ID=245189
# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber
JWT_SECRET=change-me-jwt-secret
OTP_EXPIRE_SECONDS=120
# شماره‌های ادمین پلتفرم (OTP) — با کاما جدا شوند
PLATFORM_ADMIN_MOBILES=09155105404
# ---- Frontend (Next.js) ----
FRONTEND_PORT=3000
NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
INTERNAL_TOKEN_SECRET=change-me-internal-secret
# ---- Keycloak Bootstrap ----
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=admin

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg
# Environment
.env
*.env
!.env.example
# Databases / local
*.sqlite3
*.db
test.db
# Node / Frontend
node_modules/
.next/
dist/
frontend/.next/
frontend/node_modules/
# IDE / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Logs
*.log
# Deploy temp artifacts
scripts/_deploy_bundle.tar.gz
scripts/_tmp_*
scripts/_check_*
scripts/_fix_*
scripts/_debug_*
scripts/_vols.py
scripts/_restart_*
scripts/_reset_*
scripts/_verify*
scripts/_finalize_*

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# SuperApp SaaS Platform
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
> برند، رنگ‌ها و تنظیمات هیچ‌کدام در کد hardcode نشده‌اند و همگی از `.env` / `config` / دیتابیس خوانده می‌شوند.
## معماری در یک نگاه
- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را می‌شناسد.
- **ارتباط بین سرویس‌ها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
| فایل | توضیح |
| --- | --- |
| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویس‌ها |
| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعه‌دهنده |
| [`docs/progress.md`](./docs/progress.md) | چک‌لیست پیشرفت فازها |
| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
## ساختار پروژه
```
superapp-platform/
├── backend/
│ ├── core-service/ # FastAPI Core Platform
│ ├── shared-lib/ # کتابخانه مشترک backend
│ └── services/ # placeholder سرویس‌های آینده
├── frontend/ # Next.js (کاملاً جدا)
│ ├── app/
│ ├── components/
│ ├── lib/
│ ├── hooks/
│ ├── styles/
│ └── public/
├── docs/
├── docker-compose.yml
├── .env.example
└── README.md
```
## راه‌اندازی سریع (Docker)
```bash
cp .env.example .env
docker compose up -d --build
```
سایت: http://localhost:3000
سرویس‌ها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
- Core API: http://localhost:8000 (مستندات: http://localhost:8000/docs)
- Identity API: http://localhost:8001 (SSO: http://localhost:8001/docs)
- Keycloak: http://localhost:8080 (admin/admin — کاربر نمونه: platform.admin/admin123)
- PostgreSQL: `localhost:5432`
- Redis: `localhost:6379`
> Frontend داخل Docker با hot-reload اجرا می‌شود؛ دیگر نیازی به `npm run dev` جداگانه نیست.
## راه‌اندازی محلی
### Backend
```bash
cd backend/core-service
python -m venv .venv
# Windows: .venv\Scripts\Activate.ps1
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload
```
### Frontend
```bash
# ترجیحاً از Docker (همراه بقیه سرویس‌ها):
docker compose up -d --build
# یا اجرای مستقیم برای دیباگ UI:
cd frontend
npm install
npm run dev
```
Frontend: http://localhost:3000
## اجرای تست‌ها (Backend)
```bash
cd backend/core-service
pytest -q
```
## فازهای بعدی
سرویس‌های آینده در [`backend/services/`](./backend/services) به‌صورت placeholder آماده‌اند.
UI کامل در [`frontend/`](./frontend) توسعه می‌یابد.

8
TorbatYar.code-workspace Normal file
View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

31
backend/README.md Normal file
View File

@ -0,0 +1,31 @@
# Backend
تمام کد منبع backend فقط در این پوشه قرار دارد.
## ساختار
```
backend/
├── core-service/ # سرویس Core Platform (FastAPI)
├── shared-lib/ # کتابخانه مشترک بین سرویس‌های backend
└── services/ # placeholder سرویس‌های آینده
```
## قوانین (اجباری)
- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
- frontend در پوشه جداگانه `frontend/` قرار دارد.
- ارتباط با frontend فقط از طریق REST API.
## راه‌اندازی
```bash
cd backend/core-service
python -m venv .venv
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload
```
## تست‌ها
```bash
cd backend/core-service
pytest -q
```

View File

@ -0,0 +1,28 @@
# ============================================================
# Core Platform Service - Dockerfile
# Build context: ریشه مخزن (برای دسترسی به backend/shared-lib)
# ============================================================
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/shared-lib/ /shared-lib/
COPY backend/core-service/requirements.txt /app/requirements.txt
RUN sed -i 's#-e ../shared-lib#-e /shared-lib#' /app/requirements.txt \
&& pip install --upgrade pip \
&& pip install -r requirements.txt
COPY backend/core-service/ /app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -0,0 +1,22 @@
# Dev image: فقط وابستگی‌ها — کد با volume mount و uvicorn --reload
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/shared-lib/ /shared-lib/
COPY backend/core-service/requirements.txt /app/requirements.txt
RUN sed -i 's#-e ../shared-lib#-e /shared-lib#' /app/requirements.txt \
&& pip install --upgrade pip \
&& pip install -r requirements.txt
EXPOSE 8000

View File

@ -0,0 +1,44 @@
# پیکربندی Alembic برای Core Platform Service.
# آدرس دیتابیس از env خوانده می‌شود (در alembic/env.py تنظیم شده)،
# بنابراین sqlalchemy.url اینجا خالی است.
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
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

View File

@ -0,0 +1,18 @@
# Alembic Migrations
مدیریت نسخه‌های اسکیمای دیتابیس `core_platform_db`.
## دستورها
```bash
# ساخت migration جدید بر اساس تغییر مدل‌ها
alembic revision --autogenerate -m "توضیح تغییر"
# اعمال migrationها
alembic upgrade head
# بازگشت یک مرحله
alembic downgrade -1
```
> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده می‌شود.

View File

@ -0,0 +1,59 @@
"""محیط اجرای Alembic.
از درایور sync (psycopg) برای migrationها استفاده میشود؛ آدرس آن از
تنظیمات (settings.database_url_sync) خوانده میشود تا چیزی hardcode نشود.
"""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.core.database import Base
# ثبت همه مدل‌ها روی Base.metadata برای autogenerate.
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url_sync,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
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,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -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"}

View File

@ -0,0 +1,35 @@
"""initial core platform schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-07-06
این migration اولیه، اسکیمای کامل Core Platform را از روی metadata مدلها
میسازد تا با تعریف مدلها همیشه هماهنگ بماند. migrationهای بعدی بهصورت
autogenerate و افزایشی ساخته خواهند شد.
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
from app.core.database import Base
# import مدل‌ها تا همه جدول‌ها روی metadata ثبت شوند.
import app.models # noqa: F401
revision: str = "0001_initial"
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:
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade() -> None:
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)

View File

@ -0,0 +1,64 @@
"""users table
Revision ID: 0002_users
Revises: 0001_initial
Create Date: 2026-07-06
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002_users"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("mobile", sa.String(length=15), nullable=False),
sa.Column(
"role",
sa.Enum(
"user",
"pending_tenant_admin",
"tenant_admin",
"platform_admin",
name="user_role",
),
nullable=False,
),
sa.Column(
"status",
sa.Enum("active", "inactive", "suspended", name="user_status"),
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.PrimaryKeyConstraint("id"),
)
op.create_index("ix_users_mobile", "users", ["mobile"], unique=True)
op.create_index("ix_users_status", "users", ["status"])
def downgrade() -> None:
op.drop_index("ix_users_status", table_name="users")
op.drop_index("ix_users_mobile", table_name="users")
op.drop_table("users")
op.execute("DROP TYPE IF EXISTS user_role")
op.execute("DROP TYPE IF EXISTS user_status")

View File

@ -0,0 +1,37 @@
"""promote platform admin mobiles
Revision ID: 0003_platform_admin
Revises: 0002_users
Create Date: 2026-07-07
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
revision: str = "0003_platform_admin"
down_revision: Union[str, None] = "0002_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
UPDATE users
SET role = 'platform_admin'
WHERE mobile IN ('09155105404', '9155105404')
"""
)
def downgrade() -> None:
op.execute(
"""
UPDATE users
SET role = 'pending_tenant_admin'
WHERE mobile IN ('09155105404', '9155105404')
AND role = 'platform_admin'
"""
)

View File

@ -0,0 +1,35 @@
"""user SSO link and mobile verification fields
Revision ID: 0004_user_sso_link
Revises: 0003_platform_admin
Create Date: 2026-07-07
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004_user_sso_link"
down_revision: Union[str, None] = "0003_platform_admin"
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("keycloak_sub", sa.String(length=100), nullable=True))
op.add_column("users", sa.Column("email", sa.String(length=255), nullable=True))
op.add_column(
"users",
sa.Column("mobile_verified", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"], unique=True)
op.alter_column("users", "mobile_verified", server_default=None)
def downgrade() -> None:
op.drop_index("ix_users_keycloak_sub", table_name="users")
op.drop_column("users", "mobile_verified")
op.drop_column("users", "email")
op.drop_column("users", "keycloak_sub")

View File

@ -0,0 +1,183 @@
"""tenant onboarding: profile fields, memberships, domain verification, default plans
Revision ID: 0005_tenant_onboarding
Revises: 0004_user_sso_link
Create Date: 2026-07-07
این migration زیرساخت فاز Onboarding/Activation را اضافه میکند:
- فیلدهای پروفایل/برندینگ و onboarding_completed روی tenants
- مقادیر جدید چرخه عمر tenant_status (draft/pending_activation/archived)
- ستونهای is_primary و verification_status روی domains
- جدول tenant_memberships (رابطه واقعی user <-> tenant)
- ستون current_tenant_id روی users (tenant انتخابی جاری کاربر)
- seed پلنهای پیشفرض FREE و STARTER
"""
from __future__ import annotations
import uuid
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005_tenant_onboarding"
down_revision: Union[str, None] = "0004_user_sso_link"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
is_postgres = bind.dialect.name == "postgresql"
# ---- ۱) مقادیر جدید چرخه عمر tenant_status ----
if is_postgres:
for value in ("DRAFT", "PENDING_ACTIVATION", "ARCHIVED"):
op.execute(f"ALTER TYPE tenant_status ADD VALUE IF NOT EXISTS '{value}'")
# ---- ۲) پروفایل/برندینگ روی tenants ----
op.add_column("tenants", sa.Column("business_type", sa.String(length=100), nullable=True))
op.add_column(
"tenants",
sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="fa-IR"),
)
op.add_column(
"tenants",
sa.Column("timezone", sa.String(length=50), nullable=False, server_default="Asia/Tehran"),
)
op.add_column("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True))
op.add_column("tenants", sa.Column("secondary_color", sa.String(length=20), nullable=True))
op.add_column("tenants", sa.Column("logo_url", sa.String(length=500), nullable=True))
op.add_column("tenants", sa.Column("favicon_url", sa.String(length=500), nullable=True))
op.add_column(
"tenants",
sa.Column(
"onboarding_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
)
op.alter_column("tenants", "default_locale", server_default=None)
op.alter_column("tenants", "timezone", server_default=None)
op.alter_column("tenants", "onboarding_completed", server_default=None)
# ---- ۳) وضعیت تأیید دامنه ----
domain_verification_status = sa.Enum(
"pending", "verified", "failed", name="domain_verification_status"
)
domain_verification_status.create(bind, checkfirst=True)
op.add_column(
"domains", sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false"))
)
op.add_column(
"domains",
sa.Column(
"verification_status",
domain_verification_status,
nullable=False,
server_default="pending",
),
)
op.execute("UPDATE domains SET verification_status = 'verified' WHERE is_verified = true")
op.alter_column("domains", "is_primary", server_default=None)
op.alter_column("domains", "verification_status", server_default=None)
# ---- ۴) tenant انتخابی جاری کاربر ----
op.add_column("users", sa.Column("current_tenant_id", sa.UUID(), nullable=True))
op.create_foreign_key(
"fk_users_current_tenant_id",
"users",
"tenants",
["current_tenant_id"],
["id"],
ondelete="SET NULL",
)
# ---- ۵) جدول tenant_memberships ----
op.create_table(
"tenant_memberships",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("tenant_id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column(
"role",
sa.Enum(
"platform_admin",
"tenant_owner",
"tenant_admin",
"tenant_editor",
"tenant_viewer",
name="tenant_membership_role",
),
nullable=False,
),
sa.Column(
"status",
sa.Enum("active", "invited", "disabled", name="tenant_membership_status"),
nullable=False,
),
sa.Column("is_owner", sa.Boolean(), nullable=False, server_default=sa.text("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(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
)
op.create_index("ix_tenant_memberships_tenant_id", "tenant_memberships", ["tenant_id"])
op.create_index("ix_tenant_memberships_user_id", "tenant_memberships", ["user_id"])
op.create_index("ix_tenant_memberships_role", "tenant_memberships", ["role"])
op.create_index("ix_tenant_memberships_status", "tenant_memberships", ["status"])
op.alter_column("tenant_memberships", "is_owner", server_default=None)
# ---- ۶) seed پلن‌های پیش‌فرض (idempotent — بر اساس code) ----
op.execute(
sa.text(
"""
INSERT INTO plans (id, code, name, description, is_active)
VALUES (CAST(:id AS uuid), 'FREE', 'رایگان', 'پلن پیش‌فرض onboarding — بدون هزینه', true)
ON CONFLICT (code) DO NOTHING
"""
).bindparams(id=str(uuid.uuid4()))
)
op.execute(
sa.text(
"""
INSERT INTO plans (id, code, name, description, is_active)
VALUES (CAST(:id AS uuid), 'STARTER', 'استارتر', 'پلن مقدماتی برای کسب‌وکارهای کوچک', true)
ON CONFLICT (code) DO NOTHING
"""
).bindparams(id=str(uuid.uuid4()))
)
def downgrade() -> None:
bind = op.get_bind()
op.execute("DELETE FROM plans WHERE code IN ('FREE', 'STARTER')")
op.drop_index("ix_tenant_memberships_status", table_name="tenant_memberships")
op.drop_index("ix_tenant_memberships_role", table_name="tenant_memberships")
op.drop_index("ix_tenant_memberships_user_id", table_name="tenant_memberships")
op.drop_index("ix_tenant_memberships_tenant_id", table_name="tenant_memberships")
op.drop_table("tenant_memberships")
op.execute("DROP TYPE IF EXISTS tenant_membership_status")
op.execute("DROP TYPE IF EXISTS tenant_membership_role")
op.drop_constraint("fk_users_current_tenant_id", "users", type_="foreignkey")
op.drop_column("users", "current_tenant_id")
op.drop_column("domains", "verification_status")
op.drop_column("domains", "is_primary")
sa.Enum(name="domain_verification_status").drop(bind, checkfirst=True)
op.drop_column("tenants", "onboarding_completed")
op.drop_column("tenants", "favicon_url")
op.drop_column("tenants", "logo_url")
op.drop_column("tenants", "secondary_color")
op.drop_column("tenants", "primary_color")
op.drop_column("tenants", "timezone")
op.drop_column("tenants", "default_locale")
op.drop_column("tenants", "business_type")
# توجه: مقادیر جدید tenant_status (draft/pending_activation/archived) در
# PostgreSQL قابل حذف مستقیم از نوع enum نیستند؛ در صورت نیاز باید نوع را
# بازسازی کرد. در این downgrade نادیده گرفته می‌شود.

View File

@ -0,0 +1,3 @@
"""Core Platform Service - بسته اصلی برنامه."""
__version__ = "0.1.0"

View File

@ -0,0 +1 @@
"""لایه API."""

View File

@ -0,0 +1,106 @@
"""Dependencyهای مشترک API."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from fastapi import Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, get_optional_user
from app.models.user import User
from app.services.user_service import UserService
from shared.exceptions import TenantNotResolvedError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
__all__ = [
"get_db",
"get_pagination",
"require_tenant",
"get_current_core_user",
"get_optional_core_user",
"TenantResolution",
"get_tenant_resolution",
"AsyncSession",
]
def get_pagination(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> PaginationParams:
return PaginationParams(page=page, page_size=page_size)
def require_tenant(request: Request) -> UUID:
"""اطمینان از resolve شدن tenant برای endpointهای tenant-aware.
این dependency مقدار tenant_id را از request.state (که middleware پر
کرده) میخواند و در نبود آن خطای مناسب برمیگرداند.
"""
tenant_id = getattr(request.state, "tenant_id", None)
if tenant_id is None:
raise TenantNotResolvedError(
"tenant قابل تشخیص نبود. یکی از هدرهای X-Tenant-ID یا "
"X-Tenant-Slug را ارسال کنید یا از دامنه معتبر استفاده نمایید."
)
return tenant_id
async def get_current_core_user(
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> User:
"""کاربر Core متناظر با هویت جاری (JWT محلی یا Keycloak SSO).
برای endpointهای onboarding/tenant-context استفاده میشود که نیاز به
رکورد واقعی users دارند (نه فقط claims توکن).
"""
return await UserService(db).resolve_current(user)
async def get_optional_core_user(
db: AsyncSession = Depends(get_db),
user: CurrentUser | None = Depends(get_optional_user),
) -> User | None:
if user is None:
return None
try:
return await UserService(db).resolve_current(user)
except Exception: # noqa: BLE001 - عدم لینک بودن یعنی کاربر Core ندارد
return None
@dataclass(frozen=True)
class TenantResolution:
"""نتیجه تشخیص tenant جاری request (هدر/دامنه/tenant انتخابی کاربر)."""
tenant_id: UUID | None
tenant_slug: str | None
source: str # "header" | "domain" | "user_default" | "none"
async def get_tenant_resolution(
request: Request,
core_user: User | None = Depends(get_optional_core_user),
) -> TenantResolution:
"""تشخیص tenant جاری: اول هدر/دامنه (middleware)، سپس tenant انتخابی کاربر.
این dependency جایگزین middleware نمیشود و endpointهای platform-admin
فعلی را تحت تأثیر قرار نمیدهد (صرفاً یک ابزار کمکی جدید است).
"""
tenant_id = getattr(request.state, "tenant_id", None)
tenant_slug = getattr(request.state, "tenant_slug", None)
if tenant_id is not None:
return TenantResolution(tenant_id=tenant_id, tenant_slug=tenant_slug, source="header")
if core_user is not None and core_user.current_tenant_id is not None:
return TenantResolution(
tenant_id=core_user.current_tenant_id,
tenant_slug=None,
source="user_default",
)
return TenantResolution(tenant_id=None, tenant_slug=None, source="none")

View File

@ -0,0 +1,34 @@
"""API نسخه ۱: تجمیع routerها."""
from fastapi import APIRouter
from app.api.v1 import (
auth,
domains,
features,
health,
me,
onboarding,
plans,
service_registry,
subscriptions,
tenant_context,
tenants,
)
from app.api.v1.admin import tenants as admin_tenants
api_router = APIRouter()
# health خارج از prefix نسخه هم در main اضافه می‌شود؛ اینجا برای کامل بودن.
api_router.include_router(auth.router)
api_router.include_router(admin_tenants.router)
api_router.include_router(tenants.router)
api_router.include_router(domains.router)
api_router.include_router(plans.router)
api_router.include_router(features.router)
api_router.include_router(subscriptions.router)
api_router.include_router(service_registry.router)
api_router.include_router(me.router)
api_router.include_router(onboarding.router)
api_router.include_router(tenant_context.router)
__all__ = ["api_router", "health"]

View File

@ -0,0 +1,4 @@
"""Routerهای پنل ادمین."""
from app.api.v1.admin import tenants
__all__ = ["tenants"]

View File

@ -0,0 +1,63 @@
"""APIهای مدیریت Tenant برای پنل ادمین."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.core.security import require_authenticated
from app.schemas.tenant import AdminTenantCreate, TenantRead, TenantUpdate
from app.services.tenant_service import TenantService
from shared.pagination import Page, PaginationParams
from shared.security import CurrentUser
router = APIRouter(prefix="/admin/tenants", tags=["admin-tenants"])
@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
async def create_tenant(
payload: AdminTenantCreate,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_authenticated),
) -> TenantRead:
tenant = await TenantService(db).create_for_user(user, payload)
return TenantRead.model_validate(tenant)
@router.get("", response_model=Page[TenantRead])
async def list_tenants(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_authenticated),
) -> Page[TenantRead]:
items, total = await TenantService(db).list_for_user(
user, offset=pagination.offset, limit=pagination.limit
)
return Page.create(
[TenantRead.model_validate(t) for t in items], total, pagination
)
@router.get("/{tenant_id}", response_model=TenantRead)
async def get_tenant(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_authenticated),
) -> TenantRead:
svc = TenantService(db)
tenant = await svc.get(tenant_id)
svc.ensure_access(tenant, user)
return TenantRead.model_validate(tenant)
@router.patch("/{tenant_id}", response_model=TenantRead)
async def update_tenant(
tenant_id: UUID,
payload: TenantUpdate,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_authenticated),
) -> TenantRead:
tenant = await TenantService(db).update(tenant_id, payload, user=user)
return TenantRead.model_validate(tenant)

View File

@ -0,0 +1,32 @@
"""APIهای احراز هویت OTP."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.schemas.auth import (
OtpRequestPayload,
OtpRequestResponse,
OtpVerifyPayload,
TokenResponse,
)
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/otp/request", response_model=OtpRequestResponse)
async def request_otp(
payload: OtpRequestPayload,
db: AsyncSession = Depends(get_db),
) -> OtpRequestResponse:
return await AuthService(db).request_otp(payload.mobile, force_sms=payload.force_sms, context=payload.context)
@router.post("/otp/verify", response_model=TokenResponse)
async def verify_otp(
payload: OtpVerifyPayload,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
return await AuthService(db).verify_otp(payload)

View File

@ -0,0 +1,59 @@
"""APIهای مدیریت و resolve دامنه."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.core.security import require_authenticated, require_tenant_admin
from app.schemas.domain import (
DomainCreate,
DomainRead,
DomainResolveRequest,
DomainResolveResponse,
)
from app.services.domain_service import DomainService
from app.services.tenant_service import TenantService
router = APIRouter(tags=["domains"])
@router.post(
"/tenants/{tenant_id}/domains",
response_model=DomainRead,
status_code=status.HTTP_201_CREATED,
)
async def create_domain(
tenant_id: UUID,
payload: DomainCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_tenant_admin),
) -> DomainRead:
domain = await DomainService(db).create(tenant_id, payload)
return DomainRead.model_validate(domain)
@router.get("/tenants/{tenant_id}/domains", response_model=list[DomainRead])
async def list_domains(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> list[DomainRead]:
domains = await DomainService(db).list_by_tenant(tenant_id)
return [DomainRead.model_validate(d) for d in domains]
@router.post("/domains/resolve", response_model=DomainResolveResponse)
async def resolve_domain(
payload: DomainResolveRequest, db: AsyncSession = Depends(get_db)
) -> DomainResolveResponse:
domain = await DomainService(db).resolve(payload.domain)
tenant = await TenantService(db).get(domain.tenant_id)
return DomainResolveResponse(
tenant_id=tenant.id,
tenant_slug=tenant.slug,
domain=domain.domain,
is_verified=domain.is_verified,
)

View File

@ -0,0 +1,37 @@
"""APIهای مدیریت قابلیت‌ها (Features)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.core.security import require_authenticated, require_platform_admin
from app.schemas.plan import FeatureCreate, FeatureRead
from app.services.plan_service import PlanService
from shared.pagination import Page, PaginationParams
router = APIRouter(prefix="/features", tags=["features"])
@router.post("", response_model=FeatureRead, status_code=status.HTTP_201_CREATED)
async def create_feature(
payload: FeatureCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> FeatureRead:
feature = await PlanService(db).create_feature(payload)
return FeatureRead.model_validate(feature)
@router.get("", response_model=Page[FeatureRead])
async def list_features(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> Page[FeatureRead]:
items, total = await PlanService(db).list_features(
offset=pagination.offset, limit=pagination.limit
)
return Page.create(
[FeatureRead.model_validate(f) for f in items], total, pagination
)

View File

@ -0,0 +1,24 @@
"""Endpoint سلامت سرویس."""
from __future__ import annotations
from fastapi import APIRouter
from app import __version__
from app.core.cache import cache
from app.core.config import settings
from app.schemas.common import HealthResponse
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
"""بررسی سلامت سرویس و وابستگی‌ها (Redis)."""
redis_ok = await cache.ping()
return HealthResponse(
status="ok",
service=settings.service_name,
version=__version__,
environment=settings.environment,
dependencies={"redis": "ok" if redis_ok else "unavailable"},
)

View File

@ -0,0 +1,46 @@
"""API `/me`: هویت جاری، عضویت‌ها و نیاز به onboarding."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_core_user, get_db
from app.models.user import User
from app.schemas.onboarding import MembershipSummary, MeResponse
from app.services.tenant_context_service import TenantContextService
router = APIRouter(prefix="/me", tags=["me"])
@router.get("", response_model=MeResponse)
async def get_me(
db: AsyncSession = Depends(get_db),
core_user: User = Depends(get_current_core_user),
) -> MeResponse:
memberships = await TenantContextService(db).list_memberships_summary(core_user)
onboarding_required = True
if memberships:
current = next(
(m for m in memberships if m.tenant_id == core_user.current_tenant_id),
memberships[0],
)
onboarding_required = not current.onboarding_completed
return MeResponse(
user_id=core_user.id,
mobile=core_user.mobile,
email=core_user.email,
platform_role=core_user.role.value,
onboarding_required=onboarding_required,
current_tenant_id=core_user.current_tenant_id,
memberships=memberships,
)
@router.get("/tenants", response_model=list[MembershipSummary])
async def get_my_tenants(
db: AsyncSession = Depends(get_db),
core_user: User = Depends(get_current_core_user),
) -> list[MembershipSummary]:
return await TenantContextService(db).list_memberships_summary(core_user)

View File

@ -0,0 +1,77 @@
"""APIهای Onboarding: ایجاد و فعال‌سازی tenant توسط کاربر جاری."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_core_user, get_db
from app.core.security import get_current_user
from app.models.user import User
from app.schemas.onboarding import (
OnboardingBrandingUpdate,
OnboardingDomainUpdate,
OnboardingTenantCreate,
TenantContextRead,
)
from app.services.membership_service import MembershipService
from app.services.onboarding_service import OnboardingService
from app.services.tenant_context_service import TenantContextService
from shared.security import CurrentUser
router = APIRouter(prefix="/onboarding/tenant", tags=["onboarding"])
@router.post("", response_model=TenantContextRead, status_code=status.HTTP_201_CREATED)
async def create_onboarding_tenant(
payload: OnboardingTenantCreate,
db: AsyncSession = Depends(get_db),
core_user: User = Depends(get_current_core_user),
) -> TenantContextRead:
tenant = await OnboardingService(db).create_tenant(core_user, payload)
return await TenantContextService(db).build(tenant, core_user)
@router.patch("/{tenant_id}/branding", response_model=TenantContextRead)
async def update_onboarding_branding(
tenant_id: UUID,
payload: OnboardingBrandingUpdate,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
core_user: User = Depends(get_current_core_user),
) -> TenantContextRead:
await MembershipService(db).ensure_role(
tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
)
tenant = await OnboardingService(db).update_branding(tenant_id, payload)
return await TenantContextService(db).build(tenant, core_user)
@router.patch("/{tenant_id}/domain", response_model=TenantContextRead)
async def update_onboarding_domain(
tenant_id: UUID,
payload: OnboardingDomainUpdate,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
core_user: User = Depends(get_current_core_user),
) -> TenantContextRead:
await MembershipService(db).ensure_role(
tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
)
tenant = await OnboardingService(db).update_domain(tenant_id, payload)
return await TenantContextService(db).build(tenant, core_user)
@router.post("/{tenant_id}/complete", response_model=TenantContextRead)
async def complete_onboarding(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
core_user: User = Depends(get_current_core_user),
) -> TenantContextRead:
await MembershipService(db).ensure_role(
tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
)
tenant = await OnboardingService(db).complete(tenant_id)
return await TenantContextService(db).build(tenant, core_user)

View File

@ -0,0 +1,57 @@
"""APIهای مدیریت پلن‌ها و اتصال قابلیت به پلن."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.core.security import require_authenticated, require_platform_admin
from app.schemas.plan import (
PlanCreate,
PlanFeatureCreate,
PlanFeatureRead,
PlanRead,
)
from app.services.plan_service import PlanService
from shared.pagination import Page, PaginationParams
router = APIRouter(prefix="/plans", tags=["plans"])
@router.post("", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
async def create_plan(
payload: PlanCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> PlanRead:
plan = await PlanService(db).create_plan(payload)
return PlanRead.model_validate(plan)
@router.get("", response_model=Page[PlanRead])
async def list_plans(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> Page[PlanRead]:
items, total = await PlanService(db).list_plans(
offset=pagination.offset, limit=pagination.limit
)
return Page.create([PlanRead.model_validate(p) for p in items], total, pagination)
@router.post(
"/{plan_id}/features",
response_model=PlanFeatureRead,
status_code=status.HTTP_201_CREATED,
)
async def attach_feature_to_plan(
plan_id: UUID,
payload: PlanFeatureCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> PlanFeatureRead:
plan_feature = await PlanService(db).attach_feature(plan_id, payload)
return PlanFeatureRead.model_validate(plan_feature)

View File

@ -0,0 +1,54 @@
"""APIهای ServiceRegistry."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.core.security import require_authenticated, require_platform_admin
from app.schemas.service_registry import (
ServiceCreate,
ServiceRead,
ServiceStatusUpdate,
)
from app.services.service_registry_service import ServiceRegistryService
from shared.pagination import Page, PaginationParams
router = APIRouter(prefix="/services", tags=["service-registry"])
@router.post("", response_model=ServiceRead, status_code=status.HTTP_201_CREATED)
async def create_service(
payload: ServiceCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> ServiceRead:
service = await ServiceRegistryService(db).create(payload)
return ServiceRead.model_validate(service)
@router.get("", response_model=Page[ServiceRead])
async def list_services(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> Page[ServiceRead]:
items, total = await ServiceRegistryService(db).list(
offset=pagination.offset, limit=pagination.limit
)
return Page.create(
[ServiceRead.model_validate(s) for s in items], total, pagination
)
@router.patch("/{service_id}/status", response_model=ServiceRead)
async def update_service_status(
service_id: UUID,
payload: ServiceStatusUpdate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> ServiceRead:
service = await ServiceRegistryService(db).update_status(service_id, payload.status)
return ServiceRead.model_validate(service)

View File

@ -0,0 +1,61 @@
"""APIهای اشتراک و بررسی دسترسی قابلیت."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.core.security import require_authenticated, require_tenant_admin
from app.schemas.subscription import (
FeatureCheckRequest,
FeatureCheckResponse,
SubscriptionCreate,
SubscriptionRead,
)
from app.services.entitlement_service import EntitlementService
from app.services.subscription_service import SubscriptionService
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["subscription"])
@router.post(
"/subscription",
response_model=SubscriptionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_subscription(
tenant_id: UUID,
payload: SubscriptionCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_tenant_admin),
) -> SubscriptionRead:
subscription = await SubscriptionService(db).create(tenant_id, payload)
return SubscriptionRead.model_validate(subscription)
@router.get("/subscription", response_model=SubscriptionRead)
async def get_subscription(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> SubscriptionRead:
subscription = await SubscriptionService(db).get_current(tenant_id)
return SubscriptionRead.model_validate(subscription)
@router.post("/features/check", response_model=FeatureCheckResponse)
async def check_feature(
tenant_id: UUID,
payload: FeatureCheckRequest,
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> FeatureCheckResponse:
result = await EntitlementService(db).evaluate(tenant_id, payload.feature_key)
return FeatureCheckResponse(
tenant_id=tenant_id,
feature_key=payload.feature_key,
has_access=result.has_access,
reason=result.reason,
)

View File

@ -0,0 +1,46 @@
"""APIهای Tenant Context جاری: `/tenant/current` و `/tenant/switch`."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_core_user, get_db
from app.models.enums import MembershipStatus
from app.models.user import User
from app.repositories.tenant import TenantRepository
from app.schemas.onboarding import TenantContextRead, TenantSwitchRequest
from app.services.membership_service import MembershipService
from app.services.tenant_context_service import TenantContextService
from shared.exceptions import ForbiddenError, NotFoundError
router = APIRouter(prefix="/tenant", tags=["tenant-context"])
@router.get("/current", response_model=TenantContextRead)
async def get_current_tenant(
db: AsyncSession = Depends(get_db),
core_user: User = Depends(get_current_core_user),
) -> TenantContextRead:
ctx_service = TenantContextService(db)
tenant = await ctx_service.resolve_current_tenant(core_user)
return await ctx_service.build(tenant, core_user)
@router.post("/switch", response_model=TenantContextRead)
async def switch_tenant(
payload: TenantSwitchRequest,
db: AsyncSession = Depends(get_db),
core_user: User = Depends(get_current_core_user),
) -> TenantContextRead:
membership = await MembershipService(db).get(payload.tenant_id, core_user.id)
if membership is None or membership.status != MembershipStatus.ACTIVE:
raise ForbiddenError("عضو این tenant نیستید یا عضویت غیرفعال است")
core_user.current_tenant_id = payload.tenant_id
await db.commit()
tenant = await TenantRepository(db).get(payload.tenant_id)
if tenant is None:
raise NotFoundError(f"tenant یافت نشد: {payload.tenant_id}")
ctx_service = TenantContextService(db)
return await ctx_service.build(tenant, core_user)

View File

@ -0,0 +1,80 @@
"""APIهای مدیریت Tenant."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination
from app.core.security import require_authenticated, require_platform_admin, require_tenant_admin
from app.schemas.tenant import TenantCreate, TenantRead, TenantUpdate
from app.services.tenant_service import TenantService
from shared.pagination import Page, PaginationParams
router = APIRouter(prefix="/tenants", tags=["tenants"])
@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
async def create_tenant(
payload: TenantCreate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> TenantRead:
tenant = await TenantService(db).create(payload)
return TenantRead.model_validate(tenant)
@router.get("", response_model=Page[TenantRead])
async def list_tenants(
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> Page[TenantRead]:
items, total = await TenantService(db).list(
offset=pagination.offset, limit=pagination.limit
)
return Page.create(
[TenantRead.model_validate(t) for t in items], total, pagination
)
@router.get("/{tenant_id}", response_model=TenantRead)
async def get_tenant(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_authenticated),
) -> TenantRead:
tenant = await TenantService(db).get(tenant_id)
return TenantRead.model_validate(tenant)
@router.patch("/{tenant_id}", response_model=TenantRead)
async def update_tenant(
tenant_id: UUID,
payload: TenantUpdate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_tenant_admin),
) -> TenantRead:
tenant = await TenantService(db).update(tenant_id, payload)
return TenantRead.model_validate(tenant)
@router.post("/{tenant_id}/suspend", response_model=TenantRead)
async def suspend_tenant(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> TenantRead:
tenant = await TenantService(db).suspend(tenant_id)
return TenantRead.model_validate(tenant)
@router.post("/{tenant_id}/activate", response_model=TenantRead)
async def activate_tenant(
tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_platform_admin),
) -> TenantRead:
tenant = await TenantService(db).activate(tenant_id)
return TenantRead.model_validate(tenant)

View File

@ -0,0 +1 @@
"""زیرساخت هسته: config، database، cache، security، logging."""

View File

@ -0,0 +1,129 @@
"""لایه Cache مبتنی بر Redis (async).
اگر Redis در دسترس نباشد (مثلاً در تستها)، عملیات cache بهصورت
graceful نادیده گرفته میشوند تا برنامه از کار نیفتد.
کلیدهای پیشنهادی:
tenant:{tenant_id}:metadata
tenant:{tenant_id}:features
feature_access:{tenant_id}:{feature_key}
"""
from __future__ import annotations
from typing import Any
import redis.asyncio as aioredis
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__)
class CacheClient:
"""پوشش نازک روی redis.asyncio با تحمل خطا (fail-open)."""
def __init__(self, url: str) -> None:
self._url = url
self._client: aioredis.Redis | None = None
self._enabled = True
@property
def client(self) -> aioredis.Redis | None:
if not self._enabled:
return None
if self._client is None:
self._client = aioredis.from_url(
self._url, encoding="utf-8", decode_responses=True
)
return self._client
async def get(self, key: str) -> str | None:
client = self.client
if client is None:
return None
try:
return await client.get(key)
except Exception as exc: # pragma: no cover - وابسته به محیط
logger.warning("cache_get_failed", extra={"key": key, "error": str(exc)})
return None
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
client = self.client
if client is None:
return
try:
await client.set(key, value, ex=ttl)
except Exception as exc: # pragma: no cover
logger.warning("cache_set_failed", extra={"key": key, "error": str(exc)})
async def ttl(self, key: str) -> int | None:
"""ثانیه باقی‌مانده TTL — None اگر کلید نیست یا Redis غیرفعال."""
client = self.client
if client is None:
return None
try:
remaining = await client.ttl(key)
if remaining is None or remaining < 0:
return None
return int(remaining)
except Exception as exc: # pragma: no cover
logger.warning("cache_ttl_failed", extra={"key": key, "error": str(exc)})
return None
async def delete(self, *keys: str) -> None:
client = self.client
if client is None or not keys:
return
try:
await client.delete(*keys)
except Exception as exc: # pragma: no cover
logger.warning("cache_delete_failed", extra={"keys": keys, "error": str(exc)})
async def delete_pattern(self, pattern: str) -> None:
"""حذف کلیدها بر اساس الگو (برای invalidate کردن گروهی)."""
client = self.client
if client is None:
return
try:
async for key in client.scan_iter(match=pattern):
await client.delete(key)
except Exception as exc: # pragma: no cover
logger.warning("cache_delete_pattern_failed", extra={"pattern": pattern, "error": str(exc)})
async def ping(self) -> bool:
client = self.client
if client is None:
return False
try:
return bool(await client.ping())
except Exception: # pragma: no cover
return False
async def close(self) -> None:
if self._client is not None:
try:
await self._client.aclose()
except Exception: # pragma: no cover
pass
self._client = None
def disable(self) -> None:
"""غیرفعال‌سازی cache (مثلاً در تست‌ها)."""
self._enabled = False
# ---- کمک‌کننده‌های ساخت کلید (تا نام کلیدها یکسان بماند) ----
def tenant_metadata_key(tenant_id: str) -> str:
return f"tenant:{tenant_id}:metadata"
def tenant_features_key(tenant_id: str) -> str:
return f"tenant:{tenant_id}:features"
def feature_access_key(tenant_id: str, feature_key: str) -> str:
return f"feature_access:{tenant_id}:{feature_key}"
cache = CacheClient(settings.redis_url)

View File

@ -0,0 +1,154 @@
"""تنظیمات مرکزی برنامه با Pydantic v2 Settings.
همه مقادیر از متغیرهای محیطی (env) خوانده میشوند تا هیچ چیز در کد
hardcode نشود (نام برند، رنگها، آدرسها و ...).
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ---- General ----
environment: Literal["development", "staging", "production", "test"] = "development"
debug: bool = True
log_level: str = "INFO"
service_name: str = "core-service"
api_v1_prefix: str = "/api/v1"
# ---- Platform Branding (White-label) ----
platform_name: str = "SuperApp"
platform_support_email: str = "support@example.com"
platform_primary_color: str = "#0284c7"
platform_secondary_color: str = "#0f172a"
# دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
# ---- Database ----
# آدرس async برای برنامه و sync برای Alembic.
database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"
database_url_sync: str = "postgresql+psycopg://superapp:superapp_password@localhost:5432/core_platform_db"
db_echo: bool = False
db_pool_size: int = 10
db_max_overflow: int = 20
# ---- Redis ----
redis_url: str = "redis://localhost:6379/0"
feature_access_cache_ttl: int = 300
tenant_metadata_cache_ttl: int = 300
# ---- Celery ----
celery_broker_url: str = "redis://localhost:6379/1"
celery_result_backend: str = "redis://localhost:6379/2"
# ---- Keycloak / SSO ----
keycloak_enabled: bool = False
keycloak_server_url: str = "http://localhost:8080"
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
keycloak_realm: str = "superapp"
keycloak_client_id: str = "core-service"
keycloak_client_secret: str = "change-me"
jwt_algorithm: str = "RS256"
jwt_audience: str = "account"
jwt_verify_signature: bool = False
# اگر true باشد همه endpointهای محافظت‌شده نیازمند JWT معتبر هستند.
auth_required: bool = False
cors_origins: str = Field(
default="http://localhost:3000,http://127.0.0.1:3000",
validation_alias="CORS_ORIGINS",
)
# ---- Identity Service ----
identity_service_url: str = "http://localhost:8001"
# ---- Internal Service Tokens ----
internal_token_secret: str = "change-me-internal-secret"
# ---- OTP / Payamak SMS ----
payamak_username: str = "9155105404"
payamak_from: str = Field(default="9982004961", validation_alias="PAYAMAK_FROM")
payamak_api_key: str = Field(default="", validation_alias="PAYAMAK_API_KEY")
payamak_apikey: str = Field(default="", validation_alias="PAYAMAK_APIKEY")
payamak_password: str = Field(default="", validation_alias="PAYAMAK_PASSWORD")
payamak_body_id: int = Field(default=245189, validation_alias="PAYAMAK_BODY_ID")
otp_expire_seconds: int = 120
# ---- Platform bootstrap admins (OTP) ----
platform_admin_mobiles: str = Field(
default="",
validation_alias="PLATFORM_ADMIN_MOBILES",
)
@property
def platform_admin_mobile_set(self) -> set[str]:
from app.utils.phone import normalize_mobile
result: set[str] = set()
for raw in self.platform_admin_mobiles.split(","):
item = raw.strip()
if not item:
continue
try:
result.add(normalize_mobile(item))
except ValueError:
continue
return result
@property
def payamak_auth_secret(self) -> str:
"""ApiKey در پارامتر password (طبق مستندات ملی‌پیامک)."""
return (self.payamak_api_key or self.payamak_apikey or self.payamak_password).strip()
@property
def payamak_verification_body_id(self) -> int:
"""شناسه الگوی کد تأیید — همان PAYAMAK_BODY_ID در torbatkar-back."""
return self.payamak_body_id
# ---- Local JWT (OTP users) ----
jwt_secret: str = "change-me-jwt-secret"
jwt_access_token_expire_seconds: int = 3600
jwt_refresh_token_expire_seconds: int = 604800
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def keycloak_browser_url(self) -> str:
return self.keycloak_public_url or self.keycloak_server_url
@property
def keycloak_public_realm_url(self) -> str:
return f"{self.keycloak_browser_url}/realms/{self.keycloak_realm}"
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def jwks_url(self) -> str:
"""آدرس JWKS در Keycloak برای اعتبارسنجی امضای JWT."""
return (
f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
"/protocol/openid-connect/certs"
)
@lru_cache
def get_settings() -> Settings:
"""نمونه cache شده تنظیمات (singleton)."""
return Settings()
settings = get_settings()

View File

@ -0,0 +1,50 @@
"""راه‌اندازی SQLAlchemy 2.x به‌صورت async.
- Base declarative مشترک همه مدلها
- Engine و session factory
- Dependency برای دریافت AsyncSession در FastAPI
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
"""پایه declarative مشترک برای همه مدل‌های Core."""
engine = create_async_engine(
settings.database_url,
echo=settings.db_echo,
pool_pre_ping=True,
future=True,
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency: یک AsyncSession در طول عمر request فراهم می‌کند.
در صورت بروز خطا rollback و در پایان session بسته میشود.
"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise

View File

@ -0,0 +1,61 @@
"""پیکربندی لاگ‌گیری ساخت‌یافته (structured logging).
از logging استاندارد پایتون با فرمت JSON-friendly استفاده میکنیم تا در
production قابل جمعآوری توسط ابزارهای log aggregation باشد.
"""
from __future__ import annotations
import logging
import sys
from typing import Any
from app.core.config import settings
_configured = False
class _KeyValueFormatter(logging.Formatter):
"""فرمت ساده key=value برای خوانایی و پردازش ماشینی."""
def format(self, record: logging.LogRecord) -> str:
base: dict[str, Any] = {
"level": record.levelname,
"logger": record.name,
"service": settings.service_name,
"msg": record.getMessage(),
}
if record.exc_info:
base["exc"] = self.formatException(record.exc_info)
# فیلدهای اضافی که با extra ارسال شده‌اند
for key, value in record.__dict__.items():
if key in ("args", "msg", "levelname", "name", "exc_info", "exc_text",
"stack_info", "created", "msecs", "relativeCreated", "levelno",
"pathname", "filename", "module", "funcName", "lineno",
"processName", "process", "threadName", "thread", "taskName"):
continue
base.setdefault(key, value)
return " ".join(f"{k}={v!r}" for k, v in base.items())
def configure_logging() -> None:
"""پیکربندی سراسری لاگ‌گیری (idempotent)."""
global _configured
if _configured:
return
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_KeyValueFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(settings.log_level.upper())
# کاهش نویز لاگ کتابخانه‌های پرحرف
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
_configured = True
def get_logger(name: str) -> logging.Logger:
configure_logging()
return logging.getLogger(name)

View File

@ -0,0 +1,97 @@
"""امنیت هسته: اعتبارسنجی JWT و dependencyهای احراز هویت/مجوز."""
from __future__ import annotations
from functools import lru_cache
import jwt
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from shared.auth import JWTSettings, JWTValidator, PlatformRole, has_any_role
from shared.exceptions import ForbiddenError, UnauthorizedError
from shared.security import CurrentUser
from app.services.token_service import TokenService
bearer_scheme = HTTPBearer(auto_error=False)
_token_service = TokenService()
@lru_cache
def get_jwt_validator() -> JWTValidator:
return JWTValidator(
JWTSettings(
keycloak_enabled=settings.keycloak_enabled,
keycloak_server_url=settings.keycloak_server_url,
keycloak_realm=settings.keycloak_realm,
jwt_algorithm=settings.jwt_algorithm,
jwt_audience=settings.jwt_audience,
jwt_verify_signature=settings.jwt_verify_signature,
jwt_issuer=settings.keycloak_public_realm_url,
)
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> CurrentUser:
if not settings.auth_required:
# در تست‌ها یا حالت bootstrap بدون SSO
return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
if credentials is None or not credentials.credentials:
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
token = credentials.credentials
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError as exc:
raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
alg = header.get("alg", "")
if alg == "HS256":
return _token_service.validate_access_token(token)
return await get_jwt_validator().validate(token)
async def get_optional_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> CurrentUser | None:
if not settings.auth_required:
return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
if credentials is None or not credentials.credentials:
return None
token = credentials.credentials
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError:
return None
if header.get("alg") == "HS256":
try:
return _token_service.validate_access_token(token)
except UnauthorizedError:
return None
try:
return await get_jwt_validator().validate(token)
except UnauthorizedError:
return None
def require_roles(*roles: PlatformRole | str):
"""Dependency factory: کاربر باید حداقل یکی از نقش‌ها را داشته باشد."""
async def _checker(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not settings.auth_required:
return user
if not has_any_role(user, *roles):
raise ForbiddenError("دسترسی کافی برای این عملیات ندارید")
return user
return _checker
require_platform_admin = require_roles(PlatformRole.PLATFORM_ADMIN)
require_tenant_admin = require_roles(
PlatformRole.PLATFORM_ADMIN, PlatformRole.TENANT_ADMIN
)
require_authenticated = get_current_user

View File

@ -0,0 +1,84 @@
"""نقطه ورود برنامه FastAPI برای Core Platform Service."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app import __version__
from app.api.v1 import api_router, health
from app.core.cache import cache
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.middlewares.tenant import TenantResolutionMiddleware
from shared.exceptions import AppError
from shared.responses import ErrorDetail, ErrorResponse
configure_logging()
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("service_starting", extra={"service": settings.service_name})
yield
await cache.close()
logger.info("service_stopped")
def create_app() -> FastAPI:
app = FastAPI(
title=f"{settings.platform_name} - Core Platform Service",
version=__version__,
description="سرویس هسته پلتفرم SaaS چندمستأجری (فاز ۱).",
lifespan=lifespan,
)
# CORS - در production باید origins محدود شود.
# allow_origin_regex برای ساب‌دامین tenantها (<slug>.torbatyar.ir)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# Middleware تشخیص tenant.
app.add_middleware(
TenantResolutionMiddleware, base_domain=settings.platform_base_domain or None
)
# ثبت exception handlerها برای تبدیل خطاهای دامنه به پاسخ استاندارد.
_register_exception_handlers(app)
# routerها
app.include_router(health.router)
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app
def _register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
response = ErrorResponse(
error=ErrorDetail(
code=exc.error_code, message=exc.message, details=exc.details
)
)
return JSONResponse(status_code=exc.status_code, content=response.model_dump())
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
response = ErrorResponse(
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
)
return JSONResponse(status_code=500, content=response.model_dump())
app = create_app()

View File

@ -0,0 +1 @@
"""Middlewareهای برنامه."""

View File

@ -0,0 +1,108 @@
"""Middleware تشخیص Tenant.
ترتیب تشخیص tenant:
1. هدر X-Tenant-ID
2. هدر X-Tenant-Slug
3. Subdomain (از روی Host)
4. Custom domain (از روی Host)
نتیجه در request.state قرار میگیرد:
request.state.tenant_id
request.state.tenant_slug
توجه: این middleware اگر tenant پیدا نشد خطا نمیدهد؛ صرفاً state را خالی
میگذارد. اعمال اجبار روی endpointهای tenant-aware از طریق dependency
(require_tenant) انجام میشود.
"""
from __future__ import annotations
from uuid import UUID
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core.cache import cache, tenant_metadata_key
from app.core.database import AsyncSessionLocal
from app.core.logging import get_logger
from app.repositories.tenant import DomainRepository, TenantRepository
from shared.tenant import HEADER_TENANT_ID, HEADER_TENANT_SLUG
logger = get_logger(__name__)
class TenantResolutionMiddleware(BaseHTTPMiddleware):
def __init__(self, app, *, base_domain: str | None = None) -> None:
super().__init__(app)
# base_domain برای تشخیص subdomain استفاده می‌شود (مثلاً example.com).
self.base_domain = base_domain
async def dispatch(self, request: Request, call_next) -> Response:
request.state.tenant_id = None
request.state.tenant_slug = None
try:
await self._resolve(request)
except Exception as exc: # pragma: no cover - نباید request را بشکند
logger.warning("tenant_resolution_error", extra={"error": str(exc)})
return await call_next(request)
async def _resolve(self, request: Request) -> None:
# ۱) هدر X-Tenant-ID
raw_id = request.headers.get(HEADER_TENANT_ID)
if raw_id:
try:
tenant_id = UUID(raw_id)
except ValueError:
tenant_id = None
if tenant_id is not None:
await self._set_from_tenant_id(request, tenant_id)
if request.state.tenant_id is not None:
return
# ۲) هدر X-Tenant-Slug
slug = request.headers.get(HEADER_TENANT_SLUG)
if slug:
await self._set_from_slug(request, slug.strip().lower())
if request.state.tenant_id is not None:
return
# ۳ و ۴) subdomain یا custom domain از روی Host
host = request.headers.get("host", "").split(":")[0].strip().lower()
if not host:
return
# اگر base_domain تعریف شده و host زیردامنه آن است → slug = بخش اول.
if self.base_domain and host.endswith("." + self.base_domain):
sub = host[: -(len(self.base_domain) + 1)]
if sub and sub != "www":
await self._set_from_slug(request, sub)
if request.state.tenant_id is not None:
return
# در غیر این صورت host را به‌عنوان custom domain در نظر بگیر.
await self._set_from_domain(request, host)
async def _set_from_tenant_id(self, request: Request, tenant_id: UUID) -> None:
async with AsyncSessionLocal() as session:
tenant = await TenantRepository(session).get(tenant_id)
if tenant is not None:
request.state.tenant_id = tenant.id
request.state.tenant_slug = tenant.slug
async def _set_from_slug(self, request: Request, slug: str) -> None:
async with AsyncSessionLocal() as session:
tenant = await TenantRepository(session).get_by_slug(slug)
if tenant is not None:
request.state.tenant_id = tenant.id
request.state.tenant_slug = tenant.slug
async def _set_from_domain(self, request: Request, host: str) -> None:
async with AsyncSessionLocal() as session:
domain = await DomainRepository(session).get_by_domain(host)
if domain is not None:
tenant = await TenantRepository(session).get(domain.tenant_id)
if tenant is not None:
request.state.tenant_id = tenant.id
request.state.tenant_slug = tenant.slug

View File

@ -0,0 +1,38 @@
"""مدل‌های SQLAlchemy 2.x برای Core Platform.
توجه: import همه مدلها در این ماژول لازم است تا Alembic و Base.metadata
همه جدولها را بشناسند.
"""
from app.models.audit import AuditLog
from app.models.domain import Domain
from app.models.events import InboxEvent, OutboxEvent
from app.models.membership import TenantMembership
from app.models.plan import Feature, Plan, PlanFeature
from app.models.registry import (
InternalServiceToken,
ModuleRegistry,
ServiceRegistry,
TenantModuleAccess,
)
from app.models.subscription import TenantFeatureAccess, TenantSubscription
from app.models.tenant import Tenant
from app.models.user import User
__all__ = [
"Tenant",
"User",
"TenantMembership",
"Domain",
"Plan",
"Feature",
"PlanFeature",
"TenantSubscription",
"TenantFeatureAccess",
"ServiceRegistry",
"ModuleRegistry",
"TenantModuleAccess",
"InternalServiceToken",
"OutboxEvent",
"InboxEvent",
"AuditLog",
]

View File

@ -0,0 +1,35 @@
"""مدل AuditLog: ثبت رخدادهای مهم برای پیگیری و امنیت."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.types import GUID
class AuditLog(UUIDPrimaryKeyMixin, Base):
__tablename__ = "audit_logs"
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
action: Mapped[str] = mapped_column(String(150), nullable=False)
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
resource_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
# از نام ستون meta_data برای پرهیز از تداخل با کلمه رزروشده metadata استفاده شده.
meta_data: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
Index("ix_audit_logs_tenant_id", "tenant_id"),
Index("ix_audit_logs_created_at", "created_at"),
)

View File

@ -0,0 +1,32 @@
"""Mixinهای مشترک مدل‌ها: شناسه UUID و زمان‌های ایجاد/به‌روزرسانی."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.types import GUID
class UUIDPrimaryKeyMixin:
"""کلید اصلی از نوع UUID با مقدار پیش‌فرض تولیدشده در سمت برنامه."""
id: Mapped[uuid.UUID] = mapped_column(
GUID(), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
"""ستون‌های created_at و updated_at با مدیریت خودکار."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)

View File

@ -0,0 +1,56 @@
"""مدل Domain: دامنه‌ها/زیردامنه‌های هر tenant."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.enums import DomainType, DomainVerificationStatus
from app.models.types import GUID
class Domain(UUIDPrimaryKeyMixin, Base):
__tablename__ = "domains"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
# دامنه به‌صورت یکتا در کل پلتفرم (مثلاً acme.example.com یا shop.acme.ir).
domain: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
domain_type: Mapped[DomainType] = mapped_column(
SAEnum(DomainType, name="domain_type"),
default=DomainType.SUBDOMAIN,
nullable=False,
)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# آیا این دامنه، دامنه اصلی/پیش‌فرض tenant است (مثلاً زیردامنه ساخته‌شده در onboarding).
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
verification_status: Mapped[DomainVerificationStatus] = mapped_column(
SAEnum(
DomainVerificationStatus,
name="domain_verification_status",
values_callable=lambda enum_cls: [item.value for item in enum_cls],
),
default=DomainVerificationStatus.PENDING,
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
Index("ix_domains_domain", "domain", unique=True),
Index("ix_domains_tenant_id", "tenant_id"),
)
def __repr__(self) -> str: # pragma: no cover
return f"<Domain id={self.id} domain={self.domain!r} tenant_id={self.tenant_id}>"

View File

@ -0,0 +1,79 @@
"""Enumهای دامنه مدل‌های Core."""
from __future__ import annotations
import enum
class TenantStatus(str, enum.Enum):
# چرخه عمر عملیاتی (فاز onboarding)
DRAFT = "draft"
PENDING_ACTIVATION = "pending_activation"
ACTIVE = "active"
SUSPENDED = "suspended"
ARCHIVED = "archived"
# مقادیر قدیمی (سازگاری با فاز‌های قبلی)
INACTIVE = "inactive"
DELETED = "deleted"
class TenantMembershipRole(str, enum.Enum):
"""نقش کاربر داخل یک tenant مشخص (متفاوت از نقش سراسری JWT)."""
PLATFORM_ADMIN = "platform_admin"
TENANT_OWNER = "tenant_owner"
TENANT_ADMIN = "tenant_admin"
TENANT_EDITOR = "tenant_editor"
TENANT_VIEWER = "tenant_viewer"
class MembershipStatus(str, enum.Enum):
ACTIVE = "active"
INVITED = "invited"
DISABLED = "disabled"
class DomainVerificationStatus(str, enum.Enum):
PENDING = "pending"
VERIFIED = "verified"
FAILED = "failed"
class DomainType(str, enum.Enum):
SUBDOMAIN = "subdomain"
CUSTOM_DOMAIN = "custom_domain"
class SubscriptionStatus(str, enum.Enum):
TRIALING = "trialing"
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELED = "canceled"
EXPIRED = "expired"
class LimitPeriod(str, enum.Enum):
"""دوره سقف مصرف یک قابلیت."""
DAY = "day"
MONTH = "month"
YEAR = "year"
TOTAL = "total" # بدون بازنشانی دوره‌ای
class ServiceStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
MAINTENANCE = "maintenance"
class UserRole(str, enum.Enum):
USER = "user"
PENDING_TENANT_ADMIN = "pending_tenant_admin"
TENANT_ADMIN = "tenant_admin"
PLATFORM_ADMIN = "platform_admin"
class UserStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
SUSPENDED = "suspended"

View File

@ -0,0 +1,62 @@
"""مدل‌های Outbox و Inbox برای الگوی رویداد قابل اعتماد.
OutboxEvent: رویدادهای خروجی که باید منتشر شوند.
InboxEvent: رویدادهای ورودی برای idempotency و جلوگیری از پردازش تکراری.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.types import GUID
from shared.events import EventStatus
class OutboxEvent(UUIDPrimaryKeyMixin, Base):
__tablename__ = "outbox_events"
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
aggregate_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
status: Mapped[EventStatus] = mapped_column(
SAEnum(EventStatus, name="event_status"),
default=EventStatus.PENDING,
nullable=False,
)
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_outbox_events_status", "status"),
Index("ix_outbox_events_tenant_id", "tenant_id"),
)
class InboxEvent(UUIDPrimaryKeyMixin, Base):
__tablename__ = "inbox_events"
# شناسه یکتای رویداد منبع برای جلوگیری از پردازش تکراری (idempotency).
event_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, unique=True)
source_service: Mapped[str] = mapped_column(String(100), nullable=False)
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_inbox_events_event_id", "event_id", unique=True),
Index("ix_inbox_events_tenant_id", "tenant_id"),
)

View File

@ -0,0 +1,62 @@
"""مدل TenantMembership: رابطه واقعی کاربر ↔ tenant (فاز Onboarding).
هر کاربر میتواند در آینده عضو چند tenant باشد؛ نقش و وضعیت عضویت او در هر
tenant مستقل از نقش سراسری (User.role / JWT roles) نگهداری میشود.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import MembershipStatus, TenantMembershipRole
from app.models.types import GUID
class TenantMembership(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "tenant_memberships"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[TenantMembershipRole] = mapped_column(
SAEnum(
TenantMembershipRole,
name="tenant_membership_role",
values_callable=lambda enum_cls: [item.value for item in enum_cls],
),
default=TenantMembershipRole.TENANT_VIEWER,
nullable=False,
)
status: Mapped[MembershipStatus] = mapped_column(
SAEnum(
MembershipStatus,
name="tenant_membership_status",
values_callable=lambda enum_cls: [item.value for item in enum_cls],
),
default=MembershipStatus.ACTIVE,
nullable=False,
)
is_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
Index("ix_tenant_memberships_tenant_id", "tenant_id"),
Index("ix_tenant_memberships_user_id", "user_id"),
Index("ix_tenant_memberships_role", "role"),
Index("ix_tenant_memberships_status", "status"),
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<TenantMembership tenant_id={self.tenant_id} user_id={self.user_id} "
f"role={self.role}>"
)

View File

@ -0,0 +1,74 @@
"""مدل‌های Plan، Feature و PlanFeature.
Plan: بسته اشتراک.
Feature: یک قابلیت قابل کنترل (مثلاً accounting.invoice.create).
PlanFeature: نگاشت قابلیتها به هر پلن به همراه سقف مصرف.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.enums import LimitPeriod
from app.models.types import GUID
class Plan(UUIDPrimaryKeyMixin, Base):
__tablename__ = "plans"
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (Index("ix_plans_code", "code", unique=True),)
class Feature(UUIDPrimaryKeyMixin, Base):
__tablename__ = "features"
# کلید یکتای قابلیت، مثال: accounting.invoice.create
feature_key: Mapped[str] = mapped_column(String(150), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# سرویسی که این قابلیت به آن تعلق دارد، مثال: accounting، crm.
service_key: Mapped[str] = mapped_column(String(100), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
Index("ix_features_feature_key", "feature_key", unique=True),
Index("ix_features_service_key", "service_key"),
)
class PlanFeature(UUIDPrimaryKeyMixin, Base):
__tablename__ = "plan_features"
plan_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False
)
feature_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
)
# سقف مصرف؛ None یعنی نامحدود.
limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
limit_period: Mapped[LimitPeriod | None] = mapped_column(
SAEnum(LimitPeriod, name="limit_period"), nullable=True
)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("plan_id", "feature_id", name="uq_plan_feature"),
Index("ix_plan_features_plan_id", "plan_id"),
Index("ix_plan_features_feature_id", "feature_id"),
)

View File

@ -0,0 +1,92 @@
"""مدل‌های رجیستری سرویس/ماژول و توکن‌های داخلی سرویس.
- ServiceRegistry: ثبت سرویسهای داخلی آینده و آدرسهای آنها.
- ModuleRegistry: ماژولهای قابل فعال/غیرفعالسازی برای tenantها.
- TenantModuleAccess: وضعیت فعال بودن هر ماژول برای هر tenant.
- InternalServiceToken: توکن امن ارتباط بین سرویسها (hash ذخیره میشود).
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.enums import ServiceStatus
from app.models.types import GUID
class ServiceRegistry(UUIDPrimaryKeyMixin, Base):
__tablename__ = "service_registry"
service_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[ServiceStatus] = mapped_column(
SAEnum(ServiceStatus, name="service_status"),
default=ServiceStatus.ACTIVE,
nullable=False,
)
health_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (Index("ix_service_registry_service_key", "service_key", unique=True),)
class ModuleRegistry(UUIDPrimaryKeyMixin, Base):
__tablename__ = "module_registry"
module_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# ماژول سیستمی همیشه فعال است و توسط tenant قابل غیرفعال‌سازی نیست.
is_system_module: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (Index("ix_module_registry_module_key", "module_key", unique=True),)
class TenantModuleAccess(UUIDPrimaryKeyMixin, Base):
__tablename__ = "tenant_module_access"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
module_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("module_registry.id", ondelete="CASCADE"), nullable=False
)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "module_id", name="uq_tenant_module_access"),
Index("ix_tenant_module_access_tenant_id", "tenant_id"),
)
class InternalServiceToken(UUIDPrimaryKeyMixin, Base):
__tablename__ = "internal_service_tokens"
service_key: Mapped[str] = mapped_column(String(100), nullable=False)
# فقط hash توکن ذخیره می‌شود، نه خود توکن.
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
# اسکوپ‌ها به‌صورت رشته‌ی جداشده با کاما یا فضای مجاز.
scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
Index("ix_internal_service_tokens_service_key", "service_key"),
Index("ix_internal_service_tokens_token_hash", "token_hash", unique=True),
)

View File

@ -0,0 +1,66 @@
"""مدل‌های TenantSubscription و TenantFeatureAccess."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.enums import SubscriptionStatus
from app.models.types import GUID
class TenantSubscription(UUIDPrimaryKeyMixin, Base):
__tablename__ = "tenant_subscriptions"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
plan_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[SubscriptionStatus] = mapped_column(
SAEnum(SubscriptionStatus, name="subscription_status"),
default=SubscriptionStatus.ACTIVE,
nullable=False,
)
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
Index("ix_tenant_subscriptions_tenant_id", "tenant_id"),
Index("ix_tenant_subscriptions_status", "status"),
)
class TenantFeatureAccess(UUIDPrimaryKeyMixin, Base):
"""دسترسی سفارشی/override یک tenant به یک قابلیت (فراتر از پلن)."""
__tablename__ = "tenant_feature_access"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
feature_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# سقف سفارشی؛ در صورت وجود، جایگزین سقف پلن می‌شود. None یعنی نامحدود.
custom_limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
used_value: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "feature_id", name="uq_tenant_feature_access"),
Index("ix_tenant_feature_access_tenant_id", "tenant_id"),
Index("ix_tenant_feature_access_feature_id", "feature_id"),
)

View File

@ -0,0 +1,49 @@
"""مدل Tenant: مستأجر اصلی پلتفرم."""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean
from sqlalchemy import Enum as SAEnum
from sqlalchemy import Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import TenantStatus
from app.models.types import GUID
class Tenant(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "tenants"
name: Mapped[str] = mapped_column(String(255), nullable=False)
# slug یکتا برای تشخیص tenant (مثلاً در subdomain).
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
status: Mapped[TenantStatus] = mapped_column(
SAEnum(TenantStatus, name="tenant_status"),
default=TenantStatus.ACTIVE,
nullable=False,
)
# شناسه کاربر مالک (از Identity Service آینده؛ اینجا فقط ذخیره می‌شود).
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
# ---- پروفایل/تنظیمات tenant (فاز onboarding) ----
business_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
default_locale: Mapped[str] = mapped_column(String(20), default="fa-IR", nullable=False)
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Tehran", nullable=False)
primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
secondary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
favicon_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
onboarding_completed: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
__table_args__ = (
Index("ix_tenants_slug", "slug", unique=True),
Index("ix_tenants_status", "status"),
)
def __repr__(self) -> str: # pragma: no cover
return f"<Tenant id={self.id} slug={self.slug!r} status={self.status}>"

View File

@ -0,0 +1,41 @@
"""انواع سفارشی قابل حمل دیتابیس.
GUID: روی PostgreSQL از نوع UUID و روی SQLite (تستها) از CHAR(36)
استفاده میکند تا مدلها هم در production و هم در تست کار کنند.
"""
from __future__ import annotations
import uuid
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.types import CHAR, TypeDecorator
class GUID(TypeDecorator):
"""نوع شناسه سراسری منطبق با UUID.
در PostgreSQL بهصورت native UUID و در سایر دیالکتها بهصورت رشته
۳۶ کاراکتری ذخیره میشود.
"""
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(PG_UUID(as_uuid=True))
return dialect.type_descriptor(CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return value
if dialect.name == "postgresql":
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
return str(value)
def process_result_value(self, value, dialect):
if value is None:
return value
if isinstance(value, uuid.UUID):
return value
return uuid.UUID(str(value))

View File

@ -0,0 +1,53 @@
"""مدل User: کاربران ثبت‌نام‌شده از طریق OTP."""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, Enum as SAEnum
from sqlalchemy import ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import UserRole, UserStatus
from app.models.types import GUID
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
mobile: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
keycloak_sub: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
mobile_verified: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
role: Mapped[UserRole] = mapped_column(
SAEnum(
UserRole,
name="user_role",
values_callable=lambda enum_cls: [item.value for item in enum_cls],
),
default=UserRole.PENDING_TENANT_ADMIN,
nullable=False,
)
status: Mapped[UserStatus] = mapped_column(
SAEnum(
UserStatus,
name="user_status",
values_callable=lambda enum_cls: [item.value for item in enum_cls],
),
default=UserStatus.ACTIVE,
nullable=False,
)
# tenant انتخاب‌شده جاری کاربر (برای پشتیبانی چندtenant در آینده).
current_tenant_id: Mapped[uuid.UUID | None] = mapped_column(
GUID(), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True
)
__table_args__ = (
Index("ix_users_mobile", "mobile", unique=True),
Index("ix_users_keycloak_sub", "keycloak_sub", unique=True),
Index("ix_users_status", "status"),
)
def __repr__(self) -> str: # pragma: no cover
return f"<User id={self.id} mobile={self.mobile!r} role={self.role}>"

View File

@ -0,0 +1 @@
"""لایه Repository: دسترسی به داده جدا از منطق کسب‌وکار."""

View File

@ -0,0 +1,46 @@
"""Repository پایه با عملیات CRUD عمومی (async).
هدف: جلوگیری از تکرار کد و متمرکز کردن الگوهای دسترسی به داده.
"""
from __future__ import annotations
from typing import Generic, Sequence, TypeVar
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
ModelT = TypeVar("ModelT", bound=Base)
class BaseRepository(Generic[ModelT]):
model: type[ModelT]
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, entity_id: UUID) -> ModelT | None:
return await self.session.get(self.model, entity_id)
async def add(self, entity: ModelT) -> ModelT:
self.session.add(entity)
await self.session.flush()
return entity
async def delete(self, entity: ModelT) -> None:
await self.session.delete(entity)
await self.session.flush()
async def list(
self, *, offset: int = 0, limit: int = 20
) -> Sequence[ModelT]:
stmt = select(self.model).offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count(self) -> int:
stmt = select(func.count()).select_from(self.model)
result = await self.session.execute(stmt)
return int(result.scalar_one())

View File

@ -0,0 +1,48 @@
"""Repository مربوط به TenantMembership."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.enums import MembershipStatus
from app.models.membership import TenantMembership
from app.repositories.base import BaseRepository
class TenantMembershipRepository(BaseRepository[TenantMembership]):
model = TenantMembership
async def get_by_tenant_and_user(
self, tenant_id: UUID, user_id: UUID
) -> TenantMembership | None:
stmt = select(TenantMembership).where(
TenantMembership.tenant_id == tenant_id,
TenantMembership.user_id == user_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_user(self, user_id: UUID) -> Sequence[TenantMembership]:
stmt = select(TenantMembership).where(TenantMembership.user_id == user_id)
result = await self.session.execute(stmt)
return result.scalars().all()
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
stmt = select(TenantMembership).where(TenantMembership.tenant_id == tenant_id)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_active_owners(self, tenant_id: UUID) -> int:
stmt = (
select(func.count())
.select_from(TenantMembership)
.where(
TenantMembership.tenant_id == tenant_id,
TenantMembership.is_owner.is_(True),
TenantMembership.status == MembershipStatus.ACTIVE,
)
)
result = await self.session.execute(stmt)
return int(result.scalar_one())

View File

@ -0,0 +1,57 @@
"""Repository مربوط به Plan، Feature و PlanFeature."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.plan import Feature, Plan, PlanFeature
from app.repositories.base import BaseRepository
class PlanRepository(BaseRepository[Plan]):
model = Plan
async def get_by_code(self, code: str) -> Plan | None:
stmt = select(Plan).where(Plan.code == code)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def code_exists(self, code: str) -> bool:
stmt = select(func.count()).select_from(Plan).where(Plan.code == code)
result = await self.session.execute(stmt)
return int(result.scalar_one()) > 0
class FeatureRepository(BaseRepository[Feature]):
model = Feature
async def get_by_key(self, feature_key: str) -> Feature | None:
stmt = select(Feature).where(Feature.feature_key == feature_key)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def key_exists(self, feature_key: str) -> bool:
stmt = select(func.count()).select_from(Feature).where(
Feature.feature_key == feature_key
)
result = await self.session.execute(stmt)
return int(result.scalar_one()) > 0
class PlanFeatureRepository(BaseRepository[PlanFeature]):
model = PlanFeature
async def get(self, plan_id: UUID, feature_id: UUID) -> PlanFeature | None:
stmt = select(PlanFeature).where(
PlanFeature.plan_id == plan_id,
PlanFeature.feature_id == feature_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_plan(self, plan_id: UUID) -> Sequence[PlanFeature]:
stmt = select(PlanFeature).where(PlanFeature.plan_id == plan_id)
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -0,0 +1,36 @@
"""Repository مربوط به ServiceRegistry و رویدادها."""
from __future__ import annotations
from typing import Sequence
from sqlalchemy import select
from app.models.events import OutboxEvent
from app.models.registry import ServiceRegistry
from app.repositories.base import BaseRepository
from shared.events import EventStatus
class ServiceRegistryRepository(BaseRepository[ServiceRegistry]):
model = ServiceRegistry
async def get_by_key(self, service_key: str) -> ServiceRegistry | None:
stmt = select(ServiceRegistry).where(
ServiceRegistry.service_key == service_key
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class OutboxRepository(BaseRepository[OutboxEvent]):
model = OutboxEvent
async def list_pending(self, limit: int = 100) -> Sequence[OutboxEvent]:
stmt = (
select(OutboxEvent)
.where(OutboxEvent.status == EventStatus.PENDING)
.order_by(OutboxEvent.created_at.asc())
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -0,0 +1,39 @@
"""Repository مربوط به اشتراک و دسترسی قابلیت tenant."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from app.models.subscription import TenantFeatureAccess, TenantSubscription
from app.repositories.base import BaseRepository
class SubscriptionRepository(BaseRepository[TenantSubscription]):
model = TenantSubscription
async def get_active_by_tenant(self, tenant_id: UUID) -> TenantSubscription | None:
"""آخرین اشتراک tenant را برمی‌گرداند (جدیدترین بر اساس created_at)."""
stmt = (
select(TenantSubscription)
.where(TenantSubscription.tenant_id == tenant_id)
.order_by(TenantSubscription.created_at.desc())
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class TenantFeatureAccessRepository(BaseRepository[TenantFeatureAccess]):
model = TenantFeatureAccess
async def get(
self, tenant_id: UUID, feature_id: UUID
) -> TenantFeatureAccess | None:
stmt = select(TenantFeatureAccess).where(
TenantFeatureAccess.tenant_id == tenant_id,
TenantFeatureAccess.feature_id == feature_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@ -0,0 +1,65 @@
"""Repository مربوط به Tenant و Domain."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.domain import Domain
from app.models.tenant import Tenant
from app.repositories.base import BaseRepository
class TenantRepository(BaseRepository[Tenant]):
model = Tenant
async def get_by_slug(self, slug: str) -> Tenant | None:
stmt = select(Tenant).where(Tenant.slug == slug)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def slug_exists(self, slug: str) -> bool:
stmt = select(func.count()).select_from(Tenant).where(Tenant.slug == slug)
result = await self.session.execute(stmt)
return int(result.scalar_one()) > 0
async def list_by_owner(
self, owner_user_id: UUID, *, offset: int = 0, limit: int = 20
) -> Sequence[Tenant]:
stmt = (
select(Tenant)
.where(Tenant.owner_user_id == owner_user_id)
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_by_owner(self, owner_user_id: UUID) -> int:
stmt = (
select(func.count())
.select_from(Tenant)
.where(Tenant.owner_user_id == owner_user_id)
)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class DomainRepository(BaseRepository[Domain]):
model = Domain
async def get_by_domain(self, domain: str) -> Domain | None:
stmt = select(Domain).where(Domain.domain == domain)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
stmt = select(Domain).where(Domain.tenant_id == tenant_id)
result = await self.session.execute(stmt)
return result.scalars().all()
async def domain_exists(self, domain: str) -> bool:
stmt = select(func.count()).select_from(Domain).where(Domain.domain == domain)
result = await self.session.execute(stmt)
return int(result.scalar_one()) > 0

View File

@ -0,0 +1,21 @@
"""Repository مربوط به User."""
from __future__ import annotations
from sqlalchemy import select
from app.models.user import User
from app.repositories.base import BaseRepository
class UserRepository(BaseRepository[User]):
model = User
async def get_by_mobile(self, mobile: str) -> User | None:
stmt = select(User).where(User.mobile == mobile)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_keycloak_sub(self, keycloak_sub: str) -> User | None:
stmt = select(User).where(User.keycloak_sub == keycloak_sub)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@ -0,0 +1 @@
"""اسکیماهای Pydantic v2 برای ورودی/خروجی API."""

View File

@ -0,0 +1,57 @@
"""اسکیماهای احراز هویت OTP."""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
from app.utils.phone import normalize_mobile, normalize_otp_digits
class OtpRequestPayload(BaseModel):
mobile: str = Field(..., min_length=10, max_length=15)
force_sms: bool = False
context: Literal["public", "admin"] = "public"
@field_validator("mobile")
@classmethod
def validate_mobile(cls, value: str) -> str:
return normalize_mobile(value)
class OtpRequestResponse(BaseModel):
message: str
expires_in: int
skipped: bool = False
class OtpVerifyPayload(BaseModel):
mobile: str = Field(..., min_length=10, max_length=15)
code: str = Field(..., min_length=4, max_length=8)
context: Literal["public", "admin"] = "public"
keycloak_sub: str | None = None
email: str | None = None
@field_validator("mobile")
@classmethod
def validate_mobile(cls, value: str) -> str:
return normalize_mobile(value)
@field_validator("code")
@classmethod
def validate_code(cls, value: str) -> str:
normalized = normalize_otp_digits(value)
if not normalized:
raise ValueError("کد تأیید نامعتبر است")
return normalized
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
is_new_user: bool = False
user_id: UUID
role: str

View File

@ -0,0 +1,18 @@
"""اسکیماهای پایه و مشترک."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
"""پایه اسکیماهای خروجی که از مدل ORM ساخته می‌شوند."""
model_config = ConfigDict(from_attributes=True)
class HealthResponse(BaseModel):
status: str = "ok"
service: str
version: str
environment: str
dependencies: dict[str, str] = {}

View File

@ -0,0 +1,46 @@
"""اسکیماهای Domain."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
from app.models.enums import DomainType
from app.schemas.common import ORMModel
class DomainCreate(BaseModel):
domain: str = Field(..., min_length=1, max_length=255)
domain_type: DomainType = DomainType.SUBDOMAIN
@field_validator("domain")
@classmethod
def normalize_domain(cls, value: str) -> str:
return value.strip().lower()
class DomainRead(ORMModel):
id: UUID
tenant_id: UUID
domain: str
domain_type: DomainType
is_verified: bool
verified_at: datetime | None
created_at: datetime
class DomainResolveRequest(BaseModel):
domain: str = Field(..., min_length=1, max_length=255)
@field_validator("domain")
@classmethod
def normalize_domain(cls, value: str) -> str:
return value.strip().lower()
class DomainResolveResponse(BaseModel):
tenant_id: UUID
tenant_slug: str
domain: str
is_verified: bool

View File

@ -0,0 +1,19 @@
"""اسکیماهای TenantMembership."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from app.models.enums import MembershipStatus, TenantMembershipRole
from app.schemas.common import ORMModel
class MembershipRead(ORMModel):
id: UUID
tenant_id: UUID
user_id: UUID
role: TenantMembershipRole
status: MembershipStatus
is_owner: bool
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,88 @@
"""اسکیماهای Onboarding و Tenant Context."""
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
from app.models.enums import MembershipStatus, TenantMembershipRole, TenantStatus
from app.schemas.domain import DomainRead
from app.schemas.tenant import SLUG_PATTERN, TenantRead
class OnboardingTenantCreate(BaseModel):
"""گام ۱ onboarding: ایجاد tenant توسط کاربر جاری."""
business_name: str = Field(..., min_length=1, max_length=255)
slug: str = Field(..., min_length=1, max_length=100)
business_type: str | None = Field(default=None, max_length=100)
default_locale: str = Field(default="fa-IR", max_length=20)
timezone: str = Field(default="Asia/Tehran", max_length=50)
@field_validator("slug")
@classmethod
def validate_slug(cls, value: str) -> str:
value = value.strip().lower()
if not SLUG_PATTERN.match(value):
raise ValueError(
"slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
)
return value
class OnboardingBrandingUpdate(BaseModel):
"""گام ۲ onboarding: برندینگ."""
primary_color: str | None = Field(default=None, max_length=20)
secondary_color: str | None = Field(default=None, max_length=20)
logo_url: str | None = Field(default=None, max_length=500)
favicon_url: str | None = Field(default=None, max_length=500)
class OnboardingDomainUpdate(BaseModel):
"""گام ۳ onboarding: دامنه اختصاصی (زیردامنه به‌صورت خودکار از slug ساخته می‌شود)."""
custom_domain: str | None = Field(default=None, max_length=255)
@field_validator("custom_domain")
@classmethod
def normalize_domain(cls, value: str | None) -> str | None:
if value is None or not value.strip():
return None
return value.strip().lower()
class MembershipSummary(BaseModel):
tenant_id: UUID
tenant_name: str
tenant_slug: str
tenant_status: TenantStatus
onboarding_completed: bool
role: TenantMembershipRole
status: MembershipStatus
is_owner: bool
class MeResponse(BaseModel):
user_id: UUID
mobile: str
email: str | None
platform_role: str
onboarding_required: bool
current_tenant_id: UUID | None
memberships: list[MembershipSummary]
class TenantContextRead(BaseModel):
tenant: TenantRead
role: TenantMembershipRole | None = None
is_owner: bool = False
plan_code: str | None = None
plan_name: str | None = None
subscription_status: str | None = None
domains: list[DomainRead] = Field(default_factory=list)
primary_domain: str | None = None
class TenantSwitchRequest(BaseModel):
tenant_id: UUID

View File

@ -0,0 +1,62 @@
"""اسکیماهای Plan، Feature و PlanFeature."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.enums import LimitPeriod
from app.schemas.common import ORMModel
# ---- Plan ----
class PlanCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
class PlanRead(ORMModel):
id: UUID
code: str
name: str
description: str | None
is_active: bool
created_at: datetime
# ---- Feature ----
class FeatureCreate(BaseModel):
feature_key: str = Field(..., min_length=1, max_length=150)
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
service_key: str = Field(..., min_length=1, max_length=100)
is_active: bool = True
class FeatureRead(ORMModel):
id: UUID
feature_key: str
name: str
description: str | None
service_key: str
is_active: bool
# ---- PlanFeature ----
class PlanFeatureCreate(BaseModel):
feature_id: UUID
limit_value: int | None = Field(default=None, ge=0)
limit_period: LimitPeriod | None = None
is_enabled: bool = True
class PlanFeatureRead(ORMModel):
id: UUID
plan_id: UUID
feature_id: UUID
limit_value: int | None
limit_period: LimitPeriod | None
is_enabled: bool

View File

@ -0,0 +1,32 @@
"""اسکیماهای ServiceRegistry."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.enums import ServiceStatus
from app.schemas.common import ORMModel
class ServiceCreate(BaseModel):
service_key: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
base_url: str | None = Field(default=None, max_length=500)
health_check_url: str | None = Field(default=None, max_length=500)
status: ServiceStatus = ServiceStatus.ACTIVE
class ServiceStatusUpdate(BaseModel):
status: ServiceStatus
class ServiceRead(ORMModel):
id: UUID
service_key: str
name: str
base_url: str | None
status: ServiceStatus
health_check_url: str | None
created_at: datetime

View File

@ -0,0 +1,40 @@
"""اسکیماهای اشتراک و بررسی دسترسی قابلیت."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
from app.models.enums import SubscriptionStatus
from app.schemas.common import ORMModel
class SubscriptionCreate(BaseModel):
plan_id: UUID
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
starts_at: datetime | None = None
ends_at: datetime | None = None
trial_ends_at: datetime | None = None
class SubscriptionRead(ORMModel):
id: UUID
tenant_id: UUID
plan_id: UUID
status: SubscriptionStatus
starts_at: datetime | None
ends_at: datetime | None
trial_ends_at: datetime | None
created_at: datetime
class FeatureCheckRequest(BaseModel):
feature_key: str
class FeatureCheckResponse(BaseModel):
tenant_id: UUID
feature_key: str
has_access: bool
reason: str | None = None

View File

@ -0,0 +1,79 @@
"""اسکیماهای Tenant."""
from __future__ import annotations
import re
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
from app.models.enums import TenantStatus
from app.schemas.common import ORMModel
SLUG_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
class TenantCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
slug: str = Field(..., min_length=1, max_length=100)
owner_user_id: UUID | None = None
custom_domain: str | None = Field(default=None, max_length=255)
@field_validator("slug")
@classmethod
def validate_slug(cls, value: str) -> str:
value = value.strip().lower()
if not SLUG_PATTERN.match(value):
raise ValueError(
"slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
)
return value
@field_validator("custom_domain")
@classmethod
def normalize_domain(cls, value: str | None) -> str | None:
if value is None or not value.strip():
return None
return value.strip().lower()
class AdminTenantCreate(BaseModel):
"""ایجاد tenant توسط کاربر احراز هویت‌شده (مالک = کاربر جاری)."""
name: str = Field(..., min_length=1, max_length=255)
slug: str = Field(..., min_length=1, max_length=100)
custom_domain: str | None = Field(default=None, max_length=255)
@field_validator("slug")
@classmethod
def validate_slug(cls, value: str) -> str:
return TenantCreate.validate_slug(value)
@field_validator("custom_domain")
@classmethod
def normalize_domain(cls, value: str | None) -> str | None:
return TenantCreate.normalize_domain(value)
class TenantUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
status: TenantStatus | None = None
owner_user_id: UUID | None = None
class TenantRead(ORMModel):
id: UUID
name: str
slug: str
status: TenantStatus
owner_user_id: UUID | None
business_type: str | None = None
default_locale: str = "fa-IR"
timezone: str = "Asia/Tehran"
primary_color: str | None = None
secondary_color: str | None = None
logo_url: str | None = None
favicon_url: str | None = None
onboarding_completed: bool = False
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1 @@
"""لایه Service: منطق کسب‌وکار Core Platform."""

View File

@ -0,0 +1,107 @@
"""سرویس احراز هویت: ثبت‌نام خودکار و صدور توکن."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.audit import AuditLog
from app.models.enums import UserRole, UserStatus
from app.models.user import User
from app.repositories.user import UserRepository
from app.schemas.auth import OtpRequestResponse, OtpVerifyPayload, TokenResponse
from app.services.otp_service import OtpService
from app.services.payamak_client import SmsDeliveryError
from app.services.token_service import TokenService
from shared.exceptions import AppError, UnauthorizedError
class SmsSendError(AppError):
status_code = 502
error_code = "sms_delivery_failed"
class AuthService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.users = UserRepository(session)
self.otp = OtpService()
self.tokens = TokenService()
async def request_otp(self, mobile: str, *, force_sms: bool = False, context: str = "public") -> OtpRequestResponse:
try:
expires_in, skipped = await self.otp.request_otp(mobile, force=force_sms)
except SmsDeliveryError as exc:
raise SmsSendError(str(exc)) from exc
message = "کد قبلاً ارسال شده" if skipped else "کد تأیید ارسال شد"
return OtpRequestResponse(
message=message,
expires_in=expires_in,
skipped=skipped,
)
async def verify_otp(self, payload: OtpVerifyPayload) -> TokenResponse:
if not await self.otp.verify_otp(payload.mobile, payload.code):
raise UnauthorizedError("کد تأیید نامعتبر یا منقضی شده است")
user = await self.users.get_by_mobile(payload.mobile)
is_new_user = user is None
is_platform_admin = payload.mobile in settings.platform_admin_mobile_set
if is_new_user:
if is_platform_admin:
role = UserRole.PLATFORM_ADMIN
elif payload.context == "admin":
role = UserRole.PENDING_TENANT_ADMIN
else:
role = UserRole.USER
user = User(
mobile=payload.mobile,
role=role,
status=UserStatus.ACTIVE,
mobile_verified=True,
keycloak_sub=payload.keycloak_sub,
email=payload.email,
)
await self.users.add(user)
await self._log_registration(user)
await self.session.commit()
await self.session.refresh(user)
else:
if user.status != UserStatus.ACTIVE:
raise UnauthorizedError("حساب کاربری غیرفعال است")
if is_platform_admin and user.role != UserRole.PLATFORM_ADMIN:
user.role = UserRole.PLATFORM_ADMIN
user.mobile_verified = True
if payload.keycloak_sub and not user.keycloak_sub:
user.keycloak_sub = payload.keycloak_sub
if payload.email and not user.email:
user.email = payload.email
await self.session.commit()
await self.session.refresh(user)
access_token, expires_in = self.tokens.create_access_token(
user_id=user.id,
mobile=user.mobile,
role=user.role,
)
refresh_token = self.tokens.create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=expires_in,
is_new_user=is_new_user,
user_id=user.id,
role=user.role.value,
)
async def _log_registration(self, user: User) -> None:
log = AuditLog(
tenant_id=None,
actor_user_id=user.id,
action="user.registered",
resource_type="user",
resource_id=str(user.id),
meta_data={"mobile": user.mobile, "role": user.role.value},
)
self.session.add(log)

View File

@ -0,0 +1,58 @@
"""سرویس مدیریت و resolve دامنه‌ها."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.domain import Domain
from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.domain import DomainCreate
from app.services.event_service import EventService
from shared.events import CoreEventType
from shared.exceptions import ConflictError, NotFoundError
class DomainService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DomainRepository(session)
self.tenant_repo = TenantRepository(session)
self.events = EventService(session)
async def create(self, tenant_id: UUID, data: DomainCreate) -> Domain:
tenant = await self.tenant_repo.get(tenant_id)
if tenant is None:
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
if await self.repo.domain_exists(data.domain):
raise ConflictError(
f"دامنه تکراری است: {data.domain}", error_code="domain_taken"
)
domain = Domain(
tenant_id=tenant_id,
domain=data.domain,
domain_type=data.domain_type,
)
await self.repo.add(domain)
await self.events.record(
event_type=CoreEventType.DOMAIN_CREATED.value,
aggregate_type="domain",
aggregate_id=str(domain.id),
tenant_id=tenant_id,
payload={"domain": domain.domain, "type": domain.domain_type.value},
)
await self.session.commit()
await self.session.refresh(domain)
return domain
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
return await self.repo.list_by_tenant(tenant_id)
async def resolve(self, domain: str) -> Domain:
"""یافتن دامنه و tenant متناظر آن."""
found = await self.repo.get_by_domain(domain.strip().lower())
if found is None:
raise NotFoundError(f"دامنه یافت نشد: {domain}")
return found

View File

@ -0,0 +1,117 @@
"""سرویس Entitlement: بررسی دسترسی tenant به یک قابلیت.
قوانین بررسی (به ترتیب):
1. ابتدا Redis cache بررسی میشود.
2. اگر در cache نبود، از دیتابیس محاسبه میشود.
3. نتیجه با TTL مشخص در Redis ذخیره میشود.
4. اگر اشتراک tenant فعال نبود دسترسی false.
5. اگر قابلیت در پلن فعال نبود false.
6. اگر دسترسی سفارشی (custom access) وجود داشت، آن اولویت دارد.
"""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache, feature_access_key
from app.core.config import settings
from app.models.enums import SubscriptionStatus, TenantStatus
from app.repositories.plan import FeatureRepository, PlanFeatureRepository
from app.repositories.subscription import (
SubscriptionRepository,
TenantFeatureAccessRepository,
)
from app.repositories.tenant import TenantRepository
_ALLOWED_SUBSCRIPTION_STATUSES = {
SubscriptionStatus.ACTIVE,
SubscriptionStatus.TRIALING,
}
@dataclass
class AccessResult:
has_access: bool
reason: str
class EntitlementService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.tenant_repo = TenantRepository(session)
self.feature_repo = FeatureRepository(session)
self.plan_feature_repo = PlanFeatureRepository(session)
self.subscription_repo = SubscriptionRepository(session)
self.feature_access_repo = TenantFeatureAccessRepository(session)
async def check_feature_access(
self, tenant_id: UUID, feature_key: str
) -> bool:
"""بررسی ساده دسترسی؛ فقط مقدار boolean برمی‌گرداند."""
result = await self.evaluate(tenant_id, feature_key)
return result.has_access
async def evaluate(self, tenant_id: UUID, feature_key: str) -> AccessResult:
"""بررسی کامل دسترسی همراه با دلیل، با استفاده از cache."""
cache_key = feature_access_key(str(tenant_id), feature_key)
cached = await cache.get(cache_key)
if cached is not None:
return AccessResult(has_access=cached == "1", reason="cache")
result = await self._evaluate_from_db(tenant_id, feature_key)
await cache.set(
cache_key,
"1" if result.has_access else "0",
ttl=settings.feature_access_cache_ttl,
)
return result
async def _evaluate_from_db(
self, tenant_id: UUID, feature_key: str
) -> AccessResult:
# ۱) tenant باید وجود داشته و فعال باشد.
tenant = await self.tenant_repo.get(tenant_id)
if tenant is None:
return AccessResult(False, "tenant_not_found")
if tenant.status != TenantStatus.ACTIVE:
return AccessResult(False, "tenant_inactive")
# ۲) قابلیت باید وجود داشته و فعال باشد.
feature = await self.feature_repo.get_by_key(feature_key)
if feature is None:
return AccessResult(False, "feature_not_found")
if not feature.is_active:
return AccessResult(False, "feature_inactive")
# ۳) بررسی override سفارشی tenant (بالاترین اولویت).
custom = await self.feature_access_repo.get(tenant_id, feature.id)
if custom is not None:
if not custom.is_enabled:
return AccessResult(False, "custom_disabled")
# اگر سقف سفارشی تعریف شده و مصرف از آن گذشته باشد → عدم دسترسی.
if (
custom.custom_limit_value is not None
and custom.used_value >= custom.custom_limit_value
):
return AccessResult(False, "custom_limit_exceeded")
return AccessResult(True, "custom_enabled")
# ۴) اشتراک tenant باید فعال باشد.
subscription = await self.subscription_repo.get_active_by_tenant(tenant_id)
if subscription is None:
return AccessResult(False, "no_subscription")
if subscription.status not in _ALLOWED_SUBSCRIPTION_STATUSES:
return AccessResult(False, "subscription_inactive")
# ۵) قابلیت باید در پلن اشتراک فعال باشد.
plan_feature = await self.plan_feature_repo.get(
subscription.plan_id, feature.id
)
if plan_feature is None or not plan_feature.is_enabled:
return AccessResult(False, "feature_not_in_plan")
return AccessResult(True, "plan_enabled")

View File

@ -0,0 +1,38 @@
"""سرویس ثبت رویداد در Outbox.
طبق الگوی Outbox، رویدادها ابتدا در همان تراکنش دیتابیس ذخیره میشوند و
سپس توسط worker (Celery) منتشر میگردند. این کار atomicity را تضمین میکند.
"""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.events import OutboxEvent
class EventService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def record(
self,
*,
event_type: str,
aggregate_type: str,
aggregate_id: str | None = None,
tenant_id: UUID | None = None,
payload: dict | None = None,
) -> OutboxEvent:
"""ثبت یک رویداد جدید در Outbox (بدون commit؛ در تراکنش جاری)."""
event = OutboxEvent(
event_type=event_type,
aggregate_type=aggregate_type,
aggregate_id=aggregate_id,
tenant_id=tenant_id,
payload=payload or {},
)
self.session.add(event)
await self.session.flush()
return event

View File

@ -0,0 +1,83 @@
"""سرویس مدیریت عضویت کاربر در tenant (TenantMembership)."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipStatus, TenantMembershipRole
from app.models.membership import TenantMembership
from app.repositories.membership import TenantMembershipRepository
from shared.auth import PlatformRole, has_role
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
# نقش‌هایی که اجازه مدیریت تنظیمات tenant (برندینگ/دامنه/تکمیل onboarding) را دارند.
TENANT_MANAGER_ROLES = {TenantMembershipRole.TENANT_OWNER, TenantMembershipRole.TENANT_ADMIN}
class MembershipService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = TenantMembershipRepository(session)
async def create_owner_membership(
self, tenant_id: UUID, user_id: UUID
) -> TenantMembership:
existing = await self.repo.get_by_tenant_and_user(tenant_id, user_id)
if existing is not None:
existing.role = TenantMembershipRole.TENANT_OWNER
existing.is_owner = True
existing.status = MembershipStatus.ACTIVE
await self.session.commit()
await self.session.refresh(existing)
return existing
membership = TenantMembership(
tenant_id=tenant_id,
user_id=user_id,
role=TenantMembershipRole.TENANT_OWNER,
is_owner=True,
status=MembershipStatus.ACTIVE,
)
await self.repo.add(membership)
await self.session.commit()
await self.session.refresh(membership)
return membership
async def get(self, tenant_id: UUID, user_id: UUID) -> TenantMembership | None:
return await self.repo.get_by_tenant_and_user(tenant_id, user_id)
async def list_for_user(self, user_id: UUID) -> Sequence[TenantMembership]:
return await self.repo.list_by_user(user_id)
async def list_for_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
return await self.repo.list_by_tenant(tenant_id)
async def ensure_role(
self,
*,
tenant_id: UUID,
core_user_id: UUID,
current_user: CurrentUser,
allowed_roles: set[TenantMembershipRole] = frozenset(TENANT_MANAGER_ROLES),
) -> TenantMembership | None:
"""اطمینان از این‌که کاربر جاری نقش کافی روی این tenant دارد.
platform_admin همیشه دسترسی کامل دارد (bypass).
"""
if has_role(current_user, PlatformRole.PLATFORM_ADMIN):
return None
membership = await self.get(tenant_id, core_user_id)
if (
membership is None
or membership.status != MembershipStatus.ACTIVE
or membership.role not in allowed_roles
):
raise ForbiddenError("دسترسی کافی برای مدیریت این tenant ندارید")
return membership
async def count_active_owners(self, tenant_id: UUID) -> int:
return await self.repo.count_active_owners(tenant_id)

View File

@ -0,0 +1,175 @@
"""سرویس Onboarding: ایجاد و فعال‌سازی tenant توسط کاربر جاری.
جریان کامل:
۱. POST /onboarding/tenant ایجاد tenant (status=pending_activation) + عضویت owner + پلن پیشفرض + زیردامنه خودکار
۲. PATCH /onboarding/tenant/{id}/branding تنظیم رنگ/لوگو
۳. PATCH /onboarding/tenant/{id}/domain افزودن دامنه اختصاصی (اختیاری)
۴. POST /onboarding/tenant/{id}/complete اعتبارسنجی و فعالسازی نهایی (status=active)
"""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.domain import Domain
from app.models.enums import DomainType, DomainVerificationStatus, SubscriptionStatus, TenantStatus
from app.models.tenant import Tenant
from app.models.user import User
from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.onboarding import (
OnboardingBrandingUpdate,
OnboardingDomainUpdate,
OnboardingTenantCreate,
)
from app.schemas.subscription import SubscriptionCreate
from app.schemas.tenant import TenantCreate
from app.services.event_service import EventService
from app.services.membership_service import MembershipService
from app.services.plan_service import PlanService
from app.services.subscription_service import SubscriptionService
from app.services.tenant_service import TenantService
from shared.events import CoreEventType
from shared.exceptions import ConflictError, ValidationAppError
class OnboardingService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.tenant_service = TenantService(session)
self.tenant_repo = TenantRepository(session)
self.domain_repo = DomainRepository(session)
self.membership_service = MembershipService(session)
self.plan_service = PlanService(session)
self.subscription_service = SubscriptionService(session)
self.events = EventService(session)
async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant:
tenant = await self.tenant_service.create(
TenantCreate(
name=data.business_name,
slug=data.slug,
owner_user_id=core_user.id,
)
)
tenant.status = TenantStatus.PENDING_ACTIVATION
tenant.business_type = data.business_type
tenant.default_locale = data.default_locale
tenant.timezone = data.timezone
await self.session.commit()
await self.session.refresh(tenant)
await self.membership_service.create_owner_membership(tenant.id, core_user.id)
plan = await self.plan_service.ensure_default_plan()
await self.subscription_service.create(
tenant.id,
SubscriptionCreate(plan_id=plan.id, status=SubscriptionStatus.ACTIVE),
)
await self._create_default_subdomain(tenant)
if core_user.current_tenant_id is None:
core_user.current_tenant_id = tenant.id
await self.session.commit()
await self.session.refresh(tenant)
return tenant
async def _create_default_subdomain(self, tenant: Tenant) -> None:
base_domain = (settings.platform_base_domain or "").strip().lower()
if not base_domain:
return
host = f"{tenant.slug}.{base_domain}"
if await self.domain_repo.domain_exists(host):
return
domain = Domain(
tenant_id=tenant.id,
domain=host,
domain_type=DomainType.SUBDOMAIN,
is_primary=True,
is_verified=True,
verification_status=DomainVerificationStatus.VERIFIED,
)
await self.domain_repo.add(domain)
await self.events.record(
event_type=CoreEventType.DOMAIN_CREATED.value,
aggregate_type="domain",
aggregate_id=str(domain.id),
tenant_id=tenant.id,
payload={"domain": host, "type": "subdomain", "auto_assigned": True},
)
await self.session.commit()
async def update_branding(
self, tenant_id: UUID, data: OnboardingBrandingUpdate
) -> Tenant:
tenant = await self.tenant_service.get(tenant_id)
if data.primary_color is not None:
tenant.primary_color = data.primary_color
if data.secondary_color is not None:
tenant.secondary_color = data.secondary_color
if data.logo_url is not None:
tenant.logo_url = data.logo_url
if data.favicon_url is not None:
tenant.favicon_url = data.favicon_url
await self.session.commit()
await self.session.refresh(tenant)
return tenant
async def update_domain(self, tenant_id: UUID, data: OnboardingDomainUpdate) -> Tenant:
tenant = await self.tenant_service.get(tenant_id)
if data.custom_domain:
if await self.domain_repo.domain_exists(data.custom_domain):
raise ConflictError(
f"دامنه تکراری است: {data.custom_domain}", error_code="domain_taken"
)
domain = Domain(
tenant_id=tenant.id,
domain=data.custom_domain,
domain_type=DomainType.CUSTOM_DOMAIN,
is_primary=False,
is_verified=False,
verification_status=DomainVerificationStatus.PENDING,
)
await self.domain_repo.add(domain)
await self.events.record(
event_type=CoreEventType.DOMAIN_CREATED.value,
aggregate_type="domain",
aggregate_id=str(domain.id),
tenant_id=tenant.id,
payload={"domain": data.custom_domain, "type": "custom"},
)
await self.session.commit()
await self.session.refresh(tenant)
return tenant
async def complete(self, tenant_id: UUID) -> Tenant:
tenant = await self.tenant_service.get(tenant_id)
if not tenant.name or not tenant.slug:
raise ValidationAppError(
"اطلاعات کسب‌وکار (نام/slug) تکمیل نشده است", error_code="onboarding_incomplete"
)
owners = await self.membership_service.count_active_owners(tenant_id)
if owners < 1:
raise ValidationAppError(
"tenant باید حداقل یک مالک فعال داشته باشد", error_code="owner_required"
)
tenant.onboarding_completed = True
tenant.status = TenantStatus.ACTIVE
await self.events.record(
event_type=CoreEventType.TENANT_ACTIVATED.value,
aggregate_type="tenant",
aggregate_id=str(tenant.id),
tenant_id=tenant.id,
payload={"onboarding_completed": True},
)
await self.session.commit()
await self.session.refresh(tenant)
await self.tenant_service._invalidate_cache(tenant.id)
return tenant

View File

@ -0,0 +1,77 @@
"""سرویس OTP — همان زنجیره torbatkar-back/User/utils.send_phone_otp_shared."""
from __future__ import annotations
import random
import secrets
from app.core.cache import cache
from app.core.config import settings
from app.services.payamak_client import PayamakClient, SmsDeliveryError, _normalize_phone_for_sms
from app.utils.phone import normalize_mobile, normalize_otp_digits
_memory_otp: dict[str, str] = {}
def _otp_key(mobile: str) -> str:
return f"otp:{mobile}"
class OtpService:
def __init__(self) -> None:
self.sms = PayamakClient()
def _generate_code(self) -> str:
if settings.environment == "test":
return "1234"
return str(random.randint(1111, 9999))
async def _get_stored_code(self, mobile: str) -> str | None:
key = _otp_key(mobile)
stored = await cache.get(key)
if stored is None and cache.client is None:
stored = _memory_otp.get(mobile)
return stored
async def request_otp(self, mobile: str, *, force: bool = False) -> tuple[int, bool]:
"""
Returns:
(expires_in, skipped) skipped=True یعنی کد قبلی هنوز معتبر است (torbatkar).
"""
normalized = normalize_mobile(mobile)
phone_for_sms = _normalize_phone_for_sms(normalized)
if not phone_for_sms:
raise SmsDeliveryError("شماره موبایل نامعتبر است")
key = _otp_key(normalized)
if not force:
stored = await self._get_stored_code(normalized)
if stored:
remaining = await cache.ttl(key)
expires_in = remaining if remaining else settings.otp_expire_seconds
return expires_in, True
code = self._generate_code()
ttl = settings.otp_expire_seconds
await cache.set(key, code, ttl=ttl)
if cache.client is None:
_memory_otp[normalized] = code
await self.sms.send_otp(phone_for_sms, code)
return ttl, False
async def verify_otp(self, mobile: str, code: str) -> bool:
normalized_code = normalize_otp_digits(code)
if not normalized_code:
return False
normalized = normalize_mobile(mobile)
key = _otp_key(normalized)
stored = await self._get_stored_code(normalized)
if stored is None or not secrets.compare_digest(stored, normalized_code):
return False
await cache.delete(key)
_memory_otp.pop(normalized, None)
return True

View File

@ -0,0 +1,245 @@
"""کلاینت Payamak — پورت مستقیم torbatkar-back/SMSPanel/utils.py (send_pattern_sms)."""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
import requests
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__)
# همان ثابت‌های torbatkar-back
PAYAMAK_TIMEOUT = (18, 45)
PAYAMAK_PATTERN_URL = "https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber"
PAYAMAK_REST_PATTERN_URL = "https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber"
class SmsDeliveryError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def payamak_is_configured() -> bool:
return bool(settings.payamak_username and settings.payamak_auth_secret)
def _normalize_phone_for_sms(phone: str | None) -> str | None:
"""نرمال‌سازی شماره به فرمت 09xxxxxxxxx — همان torbatkar."""
if phone is None:
return None
digits = re.sub(r"\D", "", str(phone))
if digits.startswith("0098"):
digits = digits[4:]
elif digits.startswith("98") and len(digits) >= 12:
digits = digits[2:]
if len(digits) == 10:
digits = "0" + digits
if len(digits) == 11 and digits.startswith("0"):
return digits
return None
def _parse_payamak_response_body(raw_text: str) -> dict[str, Any]:
if not raw_text or not str(raw_text).strip():
return {}
raw_text = str(raw_text).strip()
try:
data = json.loads(raw_text)
if isinstance(data, dict) and isinstance(data.get("d"), str):
try:
inner = json.loads(data["d"])
if isinstance(inner, dict):
return inner
except json.JSONDecodeError:
pass
return data if isinstance(data, dict) else {"value": data}
except json.JSONDecodeError:
pass
m = re.search(r"<string[^>]*>([\s\S]*?)</string>", raw_text, re.I)
if m:
inner = m.group(1).strip()
try:
parsed = json.loads(inner)
return parsed if isinstance(parsed, dict) else {"value": parsed}
except json.JSONDecodeError:
pass
m = re.search(r"<SendByBaseNumberResult[^>]*>([^<]+)</SendByBaseNumberResult>", raw_text, re.I)
if m:
value = m.group(1).strip()
try:
n = int(value)
return {"RetStatus": n} if n > 0 else {"RetStatus": n, "raw": value}
except ValueError:
return {"raw": value}
m = re.search(r'"RetStatus"\s*:\s*(-?\d+)', raw_text)
if m:
return {"RetStatus": int(m.group(1)), "_parse_fallback": True}
return {"raw": raw_text[:800]}
def _payamak_pattern_logical_success(response_data: dict[str, Any]) -> tuple[bool, str | None]:
if not isinstance(response_data, dict):
return False, "پاسخ نامعتبر از پنل"
ss = (response_data.get("StrRetStatus") or response_data.get("strRetStatus") or "").strip()
if ss.lower() == "ok":
return True, None
rs = response_data.get("RetStatus")
try:
n = int(float(rs))
if n > 0:
return True, None
return False, ss or f"خطای پنل پیامک (کد: {n})"
except (TypeError, ValueError):
pass
raw = response_data.get("raw")
if raw:
return False, str(raw)[:300]
return False, "نتوانستیم نتیجه ارسال را تشخیص دهیم"
def send_pattern_sms_sync(
to: str,
text: list[str] | str,
body_id: int,
*,
username: str | None = None,
password: str | None = None,
) -> dict[str, Any]:
"""ارسال پترن — کپی منطق torbatkar-back/SMSPanel/utils.send_pattern_sms."""
username = username or settings.payamak_username
password = password or settings.payamak_auth_secret
to = _normalize_phone_for_sms(to) or str(to)
if not all([username, password, to, text, body_id]):
return {"success": False, "error": "پارامترهای لازم ارسال نشده است"}
if not isinstance(text, list):
text = [text]
try:
bid = int(body_id)
except (TypeError, ValueError):
return {"success": False, "error": "شناسه الگو (bodyId) نامعتبر است"}
form_items = [
("username", str(username)),
("password", str(password)),
("to", str(to)),
("bodyId", str(bid)),
]
for part in text:
form_items.append(("text", str(part)))
json_payload = {
"username": username,
"password": password,
"to": str(to),
"text": text,
"bodyId": bid,
}
text_param = (
json.dumps(text, ensure_ascii=False)
if isinstance(text, list) and len(text) > 1
else str(text[0] if isinstance(text, list) else text)
)
rest_form = {
"username": str(username),
"password": str(password),
"to": str(to),
"bodyId": str(bid),
"text": text_param,
}
attempts = [
("rest_https", PAYAMAK_REST_PATTERN_URL, {"data": rest_form}),
("asmx_https", PAYAMAK_PATTERN_URL, {"data": form_items}),
]
response = None
response_data: dict[str, Any] = {}
success = False
err_logical: str | None = None
last_transport_error: str | None = None
for _label, url, req_kw in attempts:
try:
req_kw = {**req_kw, "timeout": PAYAMAK_TIMEOUT}
response = requests.post(url, **req_kw)
response_data = _parse_payamak_response_body(response.text)
ok_http = response.status_code == 200
ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
success = ok_http and ok_logical
if success:
break
if not ok_logical and err_logical:
last_transport_error = err_logical
except requests.RequestException as exc:
last_transport_error = str(exc)
continue
if not success:
try:
response = requests.post(PAYAMAK_PATTERN_URL, json=json_payload, timeout=PAYAMAK_TIMEOUT)
response_data = _parse_payamak_response_body(response.text)
ok_http = response.status_code == 200
ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
success = ok_http and ok_logical
except requests.RequestException as exc:
last_transport_error = str(exc)
err_msg = None if success else (err_logical or last_transport_error or "خطا در ارسال پیامک")
if success:
return {
"success": True,
"message_id": str(response_data.get("RetStatus", "")),
"api_response": response_data,
}
return {
"success": False,
"error": err_msg,
"api_response": response_data,
}
class PayamakClient:
async def send_otp(self, mobile: str, code: str) -> None:
if not payamak_is_configured():
if settings.environment in ("development", "test"):
logger.info("payamak_otp_dev_mode", extra={"mobile": mobile, "code": code})
return
raise SmsDeliveryError("تنظیمات API پیامک در سرور پیکربندی نشده است")
phone_for_sms = _normalize_phone_for_sms(mobile)
if not phone_for_sms:
raise SmsDeliveryError("شماره موبایل نامعتبر است")
result = await asyncio.to_thread(
send_pattern_sms_sync,
phone_for_sms,
[str(code)],
settings.payamak_verification_body_id,
)
if not result.get("success"):
err = result.get("error") or "ارسال پیامک ناموفق بود"
if "name resolution" in str(err).lower() or "resolve" in str(err).lower():
err = "سرویس پیامک موقتاً در دسترس نیست. لطفاً چند دقیقه بعد دوباره تلاش کنید."
logger.error(
"payamak_otp_failed",
extra={"mobile": phone_for_sms, "error": err, "api": result.get("api_response")},
)
raise SmsDeliveryError(err)
logger.info(
"payamak_otp_sent",
extra={"mobile": phone_for_sms, "message_id": result.get("message_id")},
)

View File

@ -0,0 +1,127 @@
"""سرویس مدیریت پلن‌ها، قابلیت‌ها و اتصال آن‌ها."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.plan import Feature, Plan, PlanFeature
from app.repositories.plan import (
FeatureRepository,
PlanFeatureRepository,
PlanRepository,
)
from app.schemas.plan import FeatureCreate, PlanCreate, PlanFeatureCreate
from shared.exceptions import ConflictError, NotFoundError
class PlanService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.plan_repo = PlanRepository(session)
self.feature_repo = FeatureRepository(session)
self.plan_feature_repo = PlanFeatureRepository(session)
# ---- Plans ----
async def create_plan(self, data: PlanCreate) -> Plan:
if await self.plan_repo.code_exists(data.code):
raise ConflictError(f"کد پلن تکراری است: {data.code}", error_code="plan_code_taken")
plan = Plan(
code=data.code,
name=data.name,
description=data.description,
is_active=data.is_active,
)
await self.plan_repo.add(plan)
await self.session.commit()
await self.session.refresh(plan)
return plan
async def list_plans(self, *, offset: int, limit: int) -> tuple[Sequence[Plan], int]:
items = await self.plan_repo.list(offset=offset, limit=limit)
total = await self.plan_repo.count()
return items, total
async def get_plan(self, plan_id: UUID) -> Plan:
plan = await self.plan_repo.get(plan_id)
if plan is None:
raise NotFoundError(f"پلن یافت نشد: {plan_id}")
return plan
async def ensure_default_plan(
self, *, code: str = "FREE", name: str = "رایگان"
) -> Plan:
"""پلن پیش‌فرض را برمی‌گرداند؛ در صورت نبود، آن را می‌سازد (idempotent).
برای اطمینان از وجود پلن پیشفرض هنگام onboarding استفاده میشود؛ در
کنار seed مربوط به migration (که در محیط production زودتر اجرا میشود).
"""
plan = await self.plan_repo.get_by_code(code)
if plan is not None:
return plan
plan = Plan(
code=code,
name=name,
description="پلن پیش‌فرض تخصیص‌یافته در زمان onboarding",
is_active=True,
)
await self.plan_repo.add(plan)
await self.session.commit()
await self.session.refresh(plan)
return plan
# ---- Features ----
async def create_feature(self, data: FeatureCreate) -> Feature:
if await self.feature_repo.key_exists(data.feature_key):
raise ConflictError(
f"feature_key تکراری است: {data.feature_key}",
error_code="feature_key_taken",
)
feature = Feature(
feature_key=data.feature_key,
name=data.name,
description=data.description,
service_key=data.service_key,
is_active=data.is_active,
)
await self.feature_repo.add(feature)
await self.session.commit()
await self.session.refresh(feature)
return feature
async def list_features(self, *, offset: int, limit: int) -> tuple[Sequence[Feature], int]:
items = await self.feature_repo.list(offset=offset, limit=limit)
total = await self.feature_repo.count()
return items, total
# ---- Plan <-> Feature ----
async def attach_feature(self, plan_id: UUID, data: PlanFeatureCreate) -> PlanFeature:
plan = await self.plan_repo.get(plan_id)
if plan is None:
raise NotFoundError(f"پلن یافت نشد: {plan_id}")
feature = await self.feature_repo.get(data.feature_id)
if feature is None:
raise NotFoundError(f"قابلیت یافت نشد: {data.feature_id}")
existing = await self.plan_feature_repo.get(plan_id, data.feature_id)
if existing is not None:
# به‌روزرسانی تنظیمات موجود به‌جای ایجاد رکورد تکراری.
existing.limit_value = data.limit_value
existing.limit_period = data.limit_period
existing.is_enabled = data.is_enabled
await self.session.commit()
await self.session.refresh(existing)
return existing
plan_feature = PlanFeature(
plan_id=plan_id,
feature_id=data.feature_id,
limit_value=data.limit_value,
limit_period=data.limit_period,
is_enabled=data.is_enabled,
)
await self.plan_feature_repo.add(plan_feature)
await self.session.commit()
await self.session.refresh(plan_feature)
return plan_feature

View File

@ -0,0 +1,51 @@
"""سرویس مدیریت ServiceRegistry (ثبت سرویس‌های داخلی آینده)."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ServiceStatus
from app.models.registry import ServiceRegistry
from app.repositories.registry import ServiceRegistryRepository
from app.schemas.service_registry import ServiceCreate
from shared.exceptions import ConflictError, NotFoundError
class ServiceRegistryService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = ServiceRegistryRepository(session)
async def create(self, data: ServiceCreate) -> ServiceRegistry:
if await self.repo.get_by_key(data.service_key) is not None:
raise ConflictError(
f"service_key تکراری است: {data.service_key}",
error_code="service_key_taken",
)
service = ServiceRegistry(
service_key=data.service_key,
name=data.name,
base_url=data.base_url,
health_check_url=data.health_check_url,
status=data.status,
)
await self.repo.add(service)
await self.session.commit()
await self.session.refresh(service)
return service
async def list(self, *, offset: int, limit: int) -> tuple[Sequence[ServiceRegistry], int]:
items = await self.repo.list(offset=offset, limit=limit)
total = await self.repo.count()
return items, total
async def update_status(self, service_id: UUID, status: ServiceStatus) -> ServiceRegistry:
service = await self.repo.get(service_id)
if service is None:
raise NotFoundError(f"سرویس یافت نشد: {service_id}")
service.status = status
await self.session.commit()
await self.session.refresh(service)
return service

View File

@ -0,0 +1,62 @@
"""سرویس مدیریت اشتراک tenant."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache
from app.models.subscription import TenantSubscription
from app.repositories.plan import PlanRepository
from app.repositories.subscription import SubscriptionRepository
from app.repositories.tenant import TenantRepository
from app.schemas.subscription import SubscriptionCreate
from app.services.event_service import EventService
from shared.events import CoreEventType
from shared.exceptions import NotFoundError
class SubscriptionService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = SubscriptionRepository(session)
self.tenant_repo = TenantRepository(session)
self.plan_repo = PlanRepository(session)
self.events = EventService(session)
async def create(self, tenant_id: UUID, data: SubscriptionCreate) -> TenantSubscription:
tenant = await self.tenant_repo.get(tenant_id)
if tenant is None:
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
plan = await self.plan_repo.get(data.plan_id)
if plan is None:
raise NotFoundError(f"پلن یافت نشد: {data.plan_id}")
subscription = TenantSubscription(
tenant_id=tenant_id,
plan_id=data.plan_id,
status=data.status,
starts_at=data.starts_at,
ends_at=data.ends_at,
trial_ends_at=data.trial_ends_at,
)
await self.repo.add(subscription)
await self.events.record(
event_type=CoreEventType.SUBSCRIPTION_CREATED.value,
aggregate_type="subscription",
aggregate_id=str(subscription.id),
tenant_id=tenant_id,
payload={"plan_id": str(data.plan_id), "status": data.status.value},
)
await self.session.commit()
await self.session.refresh(subscription)
# با تغییر اشتراک، cache دسترسی قابلیت‌ها باید باطل شود.
await cache.delete_pattern(f"feature_access:{tenant_id}:*")
await cache.delete(f"tenant:{tenant_id}:features")
return subscription
async def get_current(self, tenant_id: UUID) -> TenantSubscription:
subscription = await self.repo.get_active_by_tenant(tenant_id)
if subscription is None:
raise NotFoundError(f"اشتراکی برای tenant یافت نشد: {tenant_id}")
return subscription

View File

@ -0,0 +1,89 @@
"""ساخت TenantContext کامل (tenant + پلن + دامنه‌ها + نقش کاربر) برای frontend."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenant import Tenant
from app.models.user import User
from app.repositories.membership import TenantMembershipRepository
from app.repositories.plan import PlanRepository
from app.repositories.subscription import SubscriptionRepository
from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.domain import DomainRead
from app.schemas.onboarding import MembershipSummary, TenantContextRead
from app.schemas.tenant import TenantRead
from shared.exceptions import NotFoundError
class TenantContextService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.tenant_repo = TenantRepository(session)
self.domain_repo = DomainRepository(session)
self.membership_repo = TenantMembershipRepository(session)
self.subscription_repo = SubscriptionRepository(session)
self.plan_repo = PlanRepository(session)
async def build(self, tenant: Tenant, core_user: User | None) -> TenantContextRead:
membership = None
if core_user is not None:
membership = await self.membership_repo.get_by_tenant_and_user(
tenant.id, core_user.id
)
domains = await self.domain_repo.list_by_tenant(tenant.id)
primary = next((d for d in domains if d.is_primary), None)
if primary is None and domains:
primary = domains[0]
subscription = await self.subscription_repo.get_active_by_tenant(tenant.id)
plan = await self.plan_repo.get(subscription.plan_id) if subscription else None
return TenantContextRead(
tenant=TenantRead.model_validate(tenant),
role=membership.role if membership else None,
is_owner=bool(membership.is_owner) if membership else False,
plan_code=plan.code if plan else None,
plan_name=plan.name if plan else None,
subscription_status=subscription.status.value if subscription else None,
domains=[DomainRead.model_validate(d) for d in domains],
primary_domain=primary.domain if primary else None,
)
async def resolve_current_tenant(self, core_user: User) -> Tenant:
"""tenant جاری کاربر را برمی‌گرداند (بر اساس current_tenant_id یا اولین عضویت)."""
if core_user.current_tenant_id is not None:
tenant = await self.tenant_repo.get(core_user.current_tenant_id)
if tenant is not None:
return tenant
memberships = await self.membership_repo.list_by_user(core_user.id)
if memberships:
tenant = await self.tenant_repo.get(memberships[0].tenant_id)
if tenant is not None:
return tenant
raise NotFoundError("هیچ tenantی برای کاربر جاری یافت نشد")
async def list_memberships_summary(self, core_user: User) -> list[MembershipSummary]:
memberships = await self.membership_repo.list_by_user(core_user.id)
result: list[MembershipSummary] = []
for m in memberships:
tenant = await self.tenant_repo.get(m.tenant_id)
if tenant is None:
continue
result.append(
MembershipSummary(
tenant_id=tenant.id,
tenant_name=tenant.name,
tenant_slug=tenant.slug,
tenant_status=tenant.status,
onboarding_completed=tenant.onboarding_completed,
role=m.role,
status=m.status,
is_owner=m.is_owner,
)
)
return result

View File

@ -0,0 +1,156 @@
"""سرویس مدیریت Tenant."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache, tenant_metadata_key
from app.models.enums import DomainType, TenantStatus
from app.models.tenant import Tenant
from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.tenant import AdminTenantCreate, TenantCreate, TenantUpdate
from app.services.event_service import EventService
from shared.auth import PlatformRole, has_role
from shared.events import CoreEventType
from shared.exceptions import ConflictError, ForbiddenError, NotFoundError
from shared.security import CurrentUser
class TenantService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = TenantRepository(session)
self.domain_repo = DomainRepository(session)
self.events = EventService(session)
async def create(self, data: TenantCreate) -> Tenant:
# slug باید در کل پلتفرم یکتا باشد.
if await self.repo.slug_exists(data.slug):
raise ConflictError(f"slug تکراری است: {data.slug}", error_code="slug_taken")
tenant = Tenant(
name=data.name,
slug=data.slug,
owner_user_id=data.owner_user_id,
status=TenantStatus.ACTIVE,
)
await self.repo.add(tenant)
if data.custom_domain:
await self._add_custom_domain(tenant.id, data.custom_domain)
await self.events.record(
event_type=CoreEventType.TENANT_CREATED.value,
aggregate_type="tenant",
aggregate_id=str(tenant.id),
tenant_id=tenant.id,
payload={"slug": tenant.slug, "name": tenant.name},
)
await self.session.commit()
await self.session.refresh(tenant)
return tenant
async def create_for_user(self, user: CurrentUser, data: AdminTenantCreate) -> Tenant:
owner_id = UUID(user.user_id)
return await self.create(
TenantCreate(
name=data.name,
slug=data.slug,
owner_user_id=owner_id,
custom_domain=data.custom_domain,
)
)
async def _add_custom_domain(self, tenant_id: UUID, domain: str) -> None:
if await self.domain_repo.domain_exists(domain):
raise ConflictError(
f"دامنه تکراری است: {domain}", error_code="domain_taken"
)
from app.models.domain import Domain
await self.domain_repo.add(
Domain(
tenant_id=tenant_id,
domain=domain,
domain_type=DomainType.CUSTOM_DOMAIN,
)
)
async def get(self, tenant_id: UUID) -> Tenant:
tenant = await self.repo.get(tenant_id)
if tenant is None:
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
return tenant
async def get_by_slug(self, slug: str) -> Tenant | None:
return await self.repo.get_by_slug(slug)
async def list(self, *, offset: int, limit: int) -> tuple[Sequence[Tenant], int]:
items = await self.repo.list(offset=offset, limit=limit)
total = await self.repo.count()
return items, total
async def list_for_user(
self, user: CurrentUser, *, offset: int, limit: int
) -> tuple[Sequence[Tenant], int]:
if has_role(user, PlatformRole.PLATFORM_ADMIN):
return await self.list(offset=offset, limit=limit)
owner_id = UUID(user.user_id)
items = await self.repo.list_by_owner(owner_id, offset=offset, limit=limit)
total = await self.repo.count_by_owner(owner_id)
return items, total
def ensure_access(self, tenant: Tenant, user: CurrentUser) -> None:
if has_role(user, PlatformRole.PLATFORM_ADMIN):
return
if str(tenant.owner_user_id) != user.user_id:
raise ForbiddenError("دسترسی به این tenant ندارید")
async def update(
self, tenant_id: UUID, data: TenantUpdate, user: CurrentUser | None = None
) -> Tenant:
tenant = await self.get(tenant_id)
if user is not None:
self.ensure_access(tenant, user)
if data.name is not None:
tenant.name = data.name
if data.status is not None:
tenant.status = data.status
if data.owner_user_id is not None:
tenant.owner_user_id = data.owner_user_id
await self.session.commit()
await self.session.refresh(tenant)
await self._invalidate_cache(tenant_id)
return tenant
async def suspend(self, tenant_id: UUID) -> Tenant:
tenant = await self.get(tenant_id)
tenant.status = TenantStatus.SUSPENDED
await self.events.record(
event_type=CoreEventType.TENANT_SUSPENDED.value,
aggregate_type="tenant",
aggregate_id=str(tenant.id),
tenant_id=tenant.id,
)
await self.session.commit()
await self.session.refresh(tenant)
await self._invalidate_cache(tenant_id)
return tenant
async def activate(self, tenant_id: UUID) -> Tenant:
tenant = await self.get(tenant_id)
tenant.status = TenantStatus.ACTIVE
await self.events.record(
event_type=CoreEventType.TENANT_ACTIVATED.value,
aggregate_type="tenant",
aggregate_id=str(tenant.id),
tenant_id=tenant.id,
)
await self.session.commit()
await self.session.refresh(tenant)
await self._invalidate_cache(tenant_id)
return tenant
async def _invalidate_cache(self, tenant_id: UUID) -> None:
await cache.delete(tenant_metadata_key(str(tenant_id)))
await cache.delete_pattern(f"feature_access:{tenant_id}:*")

View File

@ -0,0 +1,83 @@
"""تولید و اعتبارسنجی JWT محلی برای کاربران OTP."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import UUID
import jwt
from app.core.config import settings
from app.models.enums import UserRole
from shared.exceptions import UnauthorizedError
from shared.security import CurrentUser
OTP_ISSUER = "core-service-otp"
class TokenService:
def create_access_token(
self,
*,
user_id: UUID,
mobile: str,
role: UserRole,
) -> tuple[str, int]:
expires_in = settings.jwt_access_token_expire_seconds
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"preferred_username": mobile,
"realm_access": {"roles": [role.value]},
"iss": OTP_ISSUER,
"aud": settings.jwt_audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
}
token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
return token, expires_in
def validate_access_token(self, token: str) -> CurrentUser:
try:
claims = jwt.decode(
token,
settings.jwt_secret,
algorithms=["HS256"],
audience=settings.jwt_audience,
issuer=OTP_ISSUER,
)
except jwt.PyJWTError as exc:
raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
realm_access = claims.get("realm_access") or {}
roles = list(realm_access.get("roles", [])) if isinstance(realm_access, dict) else []
return CurrentUser(
user_id=str(claims.get("sub", "")),
username=claims.get("preferred_username"),
roles=roles,
)
def create_refresh_token(self, user_id: UUID) -> str:
expires_in = settings.jwt_refresh_token_expire_seconds
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"type": "refresh",
"iss": OTP_ISSUER,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
def validate_refresh_token(self, token: str) -> UUID:
try:
claims = jwt.decode(
token,
settings.jwt_secret,
algorithms=["HS256"],
issuer=OTP_ISSUER,
)
except jwt.PyJWTError as exc:
raise UnauthorizedError(f"توکن refresh نامعتبر است: {exc}") from exc
if claims.get("type") != "refresh":
raise UnauthorizedError("توکن refresh نامعتبر است")
return UUID(str(claims["sub"]))

View File

@ -0,0 +1,49 @@
"""سرویس کمکی برای resolve کردن کاربر Core از روی CurrentUser (JWT).
توکن ممکن است از دو منبع باشد:
۱. JWT محلی OTP (HS256) sub برابر با users.id است.
۲. JWT مرکزی Keycloak (RS256) sub برابر با users.keycloak_sub است.
"""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.repositories.user import UserRepository
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
class UserService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = UserRepository(session)
async def resolve_current(self, current_user: CurrentUser) -> User:
"""کاربر Core متناظر با هویت جاری را پیدا می‌کند.
اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
تأیید نشده)، خطای قابل مدیریت در frontend برمیگرداند تا کاربر به
جریان تکمیل موبایل هدایت شود.
"""
candidate_id: UUID | None = None
try:
candidate_id = UUID(str(current_user.user_id))
except (ValueError, TypeError, AttributeError):
candidate_id = None
if candidate_id is not None:
user = await self.repo.get(candidate_id)
if user is not None:
return user
user = await self.repo.get_by_keycloak_sub(str(current_user.user_id))
if user is not None:
return user
raise ForbiddenError(
"پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
error_code="core_profile_not_linked",
)

View File

@ -0,0 +1,54 @@
"""پیکربندی مشترک تست‌ها (pytest).
تستها روی SQLite (aiosqlite) اجرا میشوند تا نیازی به PostgreSQL/Redis
واقعی نباشد. متغیرهای محیطی پیش از import ماژولهای برنامه تنظیم میشوند تا
engine و session با SQLite ساخته شوند.
"""
from __future__ import annotations
import os
# --- تنظیم محیط تست پیش از هر import از برنامه ---
os.environ["ENVIRONMENT"] = "test"
os.environ["AUTH_REQUIRED"] = "false"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_core.db"
os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_core.db"
os.environ["KEYCLOAK_ENABLED"] = "false"
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
os.environ["JWT_SECRET"] = "test-jwt-secret"
import pytest_asyncio # noqa: E402
from httpx import ASGITransport, AsyncClient # noqa: E402
import app.models # noqa: E402,F401 (ثبت مدل‌ها روی metadata)
from app.core.cache import cache # noqa: E402
from app.core.database import AsyncSessionLocal, Base, engine # noqa: E402
from app.main import app # noqa: E402
# در تست‌ها Redis در دسترس نیست؛ cache را غیرفعال می‌کنیم (fail-open).
cache.disable()
@pytest_asyncio.fixture
async def db_setup():
"""ساخت و پاک‌سازی اسکیمای دیتابیس برای هر تست (ایزوله)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def client(db_setup):
"""کلاینت HTTP async برای فراخوانی API درون‌فرآیندی."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
yield ac
@pytest_asyncio.fixture
async def session(db_setup):
"""یک AsyncSession برای تست‌های سطح سرویس/repository."""
async with AsyncSessionLocal() as s:
yield s

View File

@ -0,0 +1,41 @@
"""تست‌های مدیریت و resolve دامنه."""
from __future__ import annotations
async def _create_tenant(client, slug="domainco"):
resp = await client.post("/api/v1/tenants", json={"name": "Domain Co", "slug": slug})
return resp.json()["id"]
async def test_create_domain(client):
tenant_id = await _create_tenant(client)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/domains",
json={"domain": "shop.example.com", "domain_type": "custom_domain"},
)
assert resp.status_code == 201
data = resp.json()
assert data["domain"] == "shop.example.com"
assert data["tenant_id"] == tenant_id
async def test_resolve_domain(client):
tenant_id = await _create_tenant(client, slug="resolveco")
await client.post(
f"/api/v1/tenants/{tenant_id}/domains",
json={"domain": "resolve.example.com"},
)
resp = await client.post(
"/api/v1/domains/resolve", json={"domain": "resolve.example.com"}
)
assert resp.status_code == 200
data = resp.json()
assert data["tenant_id"] == tenant_id
assert data["tenant_slug"] == "resolveco"
async def test_resolve_unknown_domain_returns_404(client):
resp = await client.post(
"/api/v1/domains/resolve", json={"domain": "nope.example.com"}
)
assert resp.status_code == 404

View File

@ -0,0 +1,107 @@
"""تست‌های قابلیت‌ها و بررسی دسترسی (Entitlement)."""
from __future__ import annotations
async def _setup_tenant_with_plan(client, *, slug, feature_key, attach=True, active=True):
"""ساخت tenant، پلن، قابلیت، اتصال و اشتراک برای سناریوی تست."""
tenant_id = (
await client.post("/api/v1/tenants", json={"name": "T", "slug": slug})
).json()["id"]
plan_id = (
await client.post(
"/api/v1/plans", json={"code": f"plan-{slug}", "name": "Plan"}
)
).json()["id"]
feature_id = (
await client.post(
"/api/v1/features",
json={
"feature_key": feature_key,
"name": "Feature",
"service_key": feature_key.split(".")[0],
},
)
).json()["id"]
if attach:
await client.post(
f"/api/v1/plans/{plan_id}/features",
json={"feature_id": feature_id, "is_enabled": True},
)
await client.post(
f"/api/v1/tenants/{tenant_id}/subscription",
json={"plan_id": plan_id, "status": "active" if active else "expired"},
)
return tenant_id
async def test_create_feature(client):
resp = await client.post(
"/api/v1/features",
json={
"feature_key": "accounting.invoice.create",
"name": "ساخت فاکتور",
"service_key": "accounting",
},
)
assert resp.status_code == 201
assert resp.json()["feature_key"] == "accounting.invoice.create"
async def test_feature_key_unique(client):
payload = {
"feature_key": "crm.lead.create",
"name": "Lead",
"service_key": "crm",
}
first = await client.post("/api/v1/features", json=payload)
assert first.status_code == 201
second = await client.post("/api/v1/features", json=payload)
assert second.status_code == 409
async def test_check_feature_access_granted(client):
tenant_id = await _setup_tenant_with_plan(
client, slug="grant", feature_key="ecommerce.product.create"
)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/features/check",
json={"feature_key": "ecommerce.product.create"},
)
assert resp.status_code == 200
body = resp.json()
assert body["has_access"] is True
assert body["reason"] == "plan_enabled"
async def test_check_feature_access_denied_when_not_in_plan(client):
tenant_id = await _setup_tenant_with_plan(
client,
slug="denyplan",
feature_key="live_chat.widget.enable",
attach=False,
)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/features/check",
json={"feature_key": "live_chat.widget.enable"},
)
assert resp.status_code == 200
assert resp.json()["has_access"] is False
async def test_check_feature_access_denied_when_subscription_inactive(client):
tenant_id = await _setup_tenant_with_plan(
client,
slug="expired",
feature_key="ai_assistant.chat.reply",
active=False,
)
resp = await client.post(
f"/api/v1/tenants/{tenant_id}/features/check",
json={"feature_key": "ai_assistant.chat.reply"},
)
assert resp.status_code == 200
assert resp.json()["has_access"] is False

View File

@ -0,0 +1,11 @@
"""تست endpoint سلامت."""
from __future__ import annotations
async def test_health_ok(client):
resp = await client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["service"]
assert "redis" in body["dependencies"]

View File

@ -0,0 +1,55 @@
"""تست Middleware تشخیص tenant."""
from __future__ import annotations
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from app.middlewares.tenant import TenantResolutionMiddleware
from app.models.tenant import Tenant
def _build_probe_app() -> FastAPI:
"""اپ کوچک برای بازتاب tenant تشخیص‌داده‌شده از request.state."""
probe = FastAPI()
probe.add_middleware(TenantResolutionMiddleware)
@probe.get("/whoami")
async def whoami(request: Request):
tid = getattr(request.state, "tenant_id", None)
slug = getattr(request.state, "tenant_slug", None)
return {"tenant_id": str(tid) if tid else None, "tenant_slug": slug}
return probe
async def test_resolve_by_tenant_slug_header(session):
tenant = Tenant(name="Mid Co", slug="midco")
session.add(tenant)
await session.commit()
transport = ASGITransport(app=_build_probe_app())
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "midco"})
assert resp.status_code == 200
assert resp.json()["tenant_slug"] == "midco"
async def test_resolve_by_tenant_id_header(session):
tenant = Tenant(name="Mid Co2", slug="midco2")
session.add(tenant)
await session.commit()
tid = str(tenant.id)
transport = ASGITransport(app=_build_probe_app())
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
resp = await ac.get("/whoami", headers={"X-Tenant-ID": tid})
assert resp.status_code == 200
assert resp.json()["tenant_id"] == tid
async def test_unresolved_tenant_is_none(session):
transport = ASGITransport(app=_build_probe_app())
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "unknown-xyz"})
assert resp.status_code == 200
assert resp.json()["tenant_id"] is None

View File

@ -0,0 +1,216 @@
"""تست‌های فاز ۳: onboarding و فعال‌سازی workspace (tenant)."""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def enable_auth(monkeypatch):
monkeypatch.setenv("AUTH_REQUIRED", "true")
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
monkeypatch.setenv("PLATFORM_BASE_DOMAIN", "localhost")
from app.core.config import get_settings, settings
get_settings.cache_clear()
fresh = get_settings()
monkeypatch.setattr(settings, "auth_required", True)
monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
monkeypatch.setattr(settings, "platform_base_domain", fresh.platform_base_domain)
async def _login(client, mobile: str) -> dict:
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
resp = await client.post(
"/api/v1/auth/otp/verify", json={"mobile": mobile, "code": "1234"}
)
assert resp.status_code == 200
data = resp.json()
return {"Authorization": f"Bearer {data['access_token']}"}, data["user_id"]
async def test_me_requires_onboarding_for_new_user(client):
headers, user_id = await _login(client, "09121110001")
resp = await client.get("/api/v1/me", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["user_id"] == user_id
assert data["onboarding_required"] is True
assert data["current_tenant_id"] is None
assert data["memberships"] == []
async def test_full_onboarding_flow(client):
headers, user_id = await _login(client, "09121110002")
create = await client.post(
"/api/v1/onboarding/tenant",
json={
"business_name": "کافه تربت",
"slug": "cafe-torbat-2",
"business_type": "cafe",
"default_locale": "fa-IR",
"timezone": "Asia/Tehran",
},
headers=headers,
)
assert create.status_code == 201, create.text
body = create.json()
tenant_id = body["tenant"]["id"]
assert body["tenant"]["status"] == "pending_activation"
assert body["tenant"]["onboarding_completed"] is False
assert body["role"] == "tenant_owner"
assert body["is_owner"] is True
assert body["plan_code"] == "FREE"
assert body["primary_domain"] == "cafe-torbat-2.localhost"
assert body["domains"][0]["domain_type"] == "subdomain"
assert body["domains"][0]["is_verified"] is True
# اگر دوباره تلاش کنیم tenant دیگری بسازیم روی همین slug، باید خطای تکراری بگیریم.
dup = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "X", "slug": "cafe-torbat-2"},
headers=headers,
)
assert dup.status_code == 409
assert dup.json()["error"]["code"] == "slug_taken"
branding = await client.patch(
f"/api/v1/onboarding/tenant/{tenant_id}/branding",
json={"primary_color": "#112233", "secondary_color": "#445566"},
headers=headers,
)
assert branding.status_code == 200
assert branding.json()["tenant"]["primary_color"] == "#112233"
domain = await client.patch(
f"/api/v1/onboarding/tenant/{tenant_id}/domain",
json={"custom_domain": "cafe2.example.com"},
headers=headers,
)
assert domain.status_code == 200
hosts = [d["domain"] for d in domain.json()["domains"]]
assert "cafe2.example.com" in hosts
complete = await client.post(
f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
)
assert complete.status_code == 200
completed_body = complete.json()
assert completed_body["tenant"]["status"] == "active"
assert completed_body["tenant"]["onboarding_completed"] is True
me = await client.get("/api/v1/me", headers=headers)
me_data = me.json()
assert me_data["onboarding_required"] is False
assert me_data["current_tenant_id"] == tenant_id
assert len(me_data["memberships"]) == 1
assert me_data["memberships"][0]["role"] == "tenant_owner"
current = await client.get("/api/v1/tenant/current", headers=headers)
assert current.status_code == 200
assert current.json()["tenant"]["id"] == tenant_id
my_tenants = await client.get("/api/v1/me/tenants", headers=headers)
assert my_tenants.status_code == 200
slugs = [t["tenant_slug"] for t in my_tenants.json()]
assert "cafe-torbat-2" in slugs
async def test_onboarding_tenant_requires_auth(client):
resp = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "X", "slug": "no-auth-tenant"},
)
assert resp.status_code == 401
async def test_branding_forbidden_for_non_member(client):
headers_owner, _ = await _login(client, "09121110003")
create = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "Owner Co", "slug": "owner-co"},
headers=headers_owner,
)
tenant_id = create.json()["tenant"]["id"]
headers_other, _ = await _login(client, "09121110004")
resp = await client.patch(
f"/api/v1/onboarding/tenant/{tenant_id}/branding",
json={"primary_color": "#000000"},
headers=headers_other,
)
assert resp.status_code == 403
async def test_complete_fails_without_required_fields(client):
headers, _ = await _login(client, "09121110005")
create = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "بدون برند", "slug": "no-brand-co"},
headers=headers,
)
tenant_id = create.json()["tenant"]["id"]
# بدون تنظیم برندینگ کامل هم می‌توان تکمیل کرد (فیلدهای برندینگ اختیاری‌اند)،
# اما تکمیل روی tenant دیگری که عضو آن نیستیم باید 403/404 بدهد.
other_headers, _ = await _login(client, "09121110006")
resp = await client.post(
f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=other_headers
)
assert resp.status_code == 403
ok = await client.post(
f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
)
assert ok.status_code == 200
assert ok.json()["tenant"]["status"] == "active"
async def test_tenant_switch_between_multiple_tenants(client):
headers, user_id = await _login(client, "09121110007")
first = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "First Co", "slug": "first-co"},
headers=headers,
)
first_id = first.json()["tenant"]["id"]
second = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "Second Co", "slug": "second-co"},
headers=headers,
)
second_id = second.json()["tenant"]["id"]
# اولین tenant ساخته‌شده به‌عنوان current انتخاب می‌شود (چون current_tenant_id قبلاً خالی بود)؛
# ساخت tenant دوم آن را تغییر نمی‌دهد.
me = await client.get("/api/v1/me", headers=headers)
assert me.json()["current_tenant_id"] == first_id
assert len(me.json()["memberships"]) == 2
switch = await client.post(
"/api/v1/tenant/switch", json={"tenant_id": second_id}, headers=headers
)
assert switch.status_code == 200
assert switch.json()["tenant"]["id"] == second_id
current = await client.get("/api/v1/tenant/current", headers=headers)
assert current.json()["tenant"]["id"] == second_id
async def test_tenant_switch_forbidden_for_non_member(client):
headers_owner, _ = await _login(client, "09121110008")
create = await client.post(
"/api/v1/onboarding/tenant",
json={"business_name": "Solo Co", "slug": "solo-co"},
headers=headers_owner,
)
tenant_id = create.json()["tenant"]["id"]
headers_other, _ = await _login(client, "09121110009")
resp = await client.post(
"/api/v1/tenant/switch", json={"tenant_id": tenant_id}, headers=headers_other
)
assert resp.status_code == 403

View File

@ -0,0 +1,128 @@
"""تست‌های OTP و ثبت‌نام خودکار."""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def enable_auth(monkeypatch):
monkeypatch.setenv("AUTH_REQUIRED", "true")
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
from app.core.config import get_settings, settings
get_settings.cache_clear()
fresh = get_settings()
monkeypatch.setattr(settings, "auth_required", True)
monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
async def test_otp_request_and_verify_new_user(client):
mobile = "09123456789"
req = await client.post(
"/api/v1/auth/otp/request",
json={"mobile": mobile, "context": "admin"},
)
assert req.status_code == 200
assert req.json()["expires_in"] == 120
verify = await client.post(
"/api/v1/auth/otp/verify",
json={"mobile": mobile, "code": "1234", "context": "admin"},
)
assert verify.status_code == 200
data = verify.json()
assert data["is_new_user"] is True
assert data["role"] == "pending_tenant_admin"
assert "access_token" in data
assert "refresh_token" in data
async def test_otp_verify_existing_user(client):
mobile = "09111111111"
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
first = await client.post(
"/api/v1/auth/otp/verify",
json={"mobile": mobile, "code": "1234"},
)
assert first.json()["is_new_user"] is True
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
second = await client.post(
"/api/v1/auth/otp/verify",
json={"mobile": mobile, "code": "1234"},
)
assert second.status_code == 200
assert second.json()["is_new_user"] is False
async def test_otp_verify_invalid_code(client):
mobile = "09222222222"
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
resp = await client.post(
"/api/v1/auth/otp/verify",
json={"mobile": mobile, "code": "000000"},
)
assert resp.status_code == 401
async def test_otp_request_skips_unexpired_code(client):
mobile = "09444444444"
first = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
assert first.status_code == 200
assert first.json()["skipped"] is False
second = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
assert second.status_code == 200
body = second.json()
assert body["skipped"] is True
assert body["message"] == "کد قبلاً ارسال شده"
forced = await client.post(
"/api/v1/auth/otp/request",
json={"mobile": mobile, "force_sms": True},
)
assert forced.status_code == 200
assert forced.json()["skipped"] is False
async def test_platform_admin_mobile_gets_admin_role(client, monkeypatch):
from app.core.config import get_settings, settings
monkeypatch.setenv("PLATFORM_ADMIN_MOBILES", "09155105404")
get_settings.cache_clear()
fresh = get_settings()
monkeypatch.setattr(settings, "platform_admin_mobiles", fresh.platform_admin_mobiles)
mobile = "09155105404"
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
verify = await client.post(
"/api/v1/auth/otp/verify",
json={"mobile": mobile, "code": "1234"},
)
assert verify.status_code == 200
assert verify.json()["role"] == "platform_admin"
async def test_admin_tenant_with_otp_token(client):
mobile = "09333333333"
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
auth = await client.post(
"/api/v1/auth/otp/verify",
json={"mobile": mobile, "code": "1234"},
)
token = auth.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
created = await client.post(
"/api/v1/admin/tenants",
json={"name": "My Shop", "slug": "my-shop"},
headers=headers,
)
assert created.status_code == 201
body = created.json()
assert body["slug"] == "my-shop"
assert body["owner_user_id"] == auth.json()["user_id"]
listed = await client.get("/api/v1/admin/tenants", headers=headers)
assert listed.status_code == 200
assert listed.json()["meta"]["total_items"] == 1

View File

@ -0,0 +1,35 @@
"""تست پردازش رویدادهای Outbox."""
from __future__ import annotations
from sqlalchemy import select
from app.models.events import OutboxEvent
from app.services.tenant_service import TenantService
from app.schemas.tenant import TenantCreate
from app.workers.tasks import process_pending_outbox
from shared.events import EventStatus
async def test_outbox_event_recorded_on_tenant_create(session):
await TenantService(session).create(
TenantCreate(name="Evt Co", slug="evtco")
)
result = await session.execute(select(OutboxEvent))
events = result.scalars().all()
assert len(events) == 1
assert events[0].event_type == "tenant.created"
assert events[0].status == EventStatus.PENDING
async def test_process_pending_outbox(session):
await TenantService(session).create(
TenantCreate(name="Proc Co", slug="procco")
)
summary = await process_pending_outbox(session)
assert summary["processed"] == 1
assert summary["failed"] == 0
result = await session.execute(select(OutboxEvent))
events = result.scalars().all()
assert all(e.status == EventStatus.PROCESSED for e in events)
assert all(e.processed_at is not None for e in events)

View File

@ -0,0 +1,35 @@
"""تست پارس و نرمال‌سازی Payamak — هم‌منطق torbatkar-back/SMSPanel/utils."""
from __future__ import annotations
from app.services.payamak_client import (
_normalize_phone_for_sms,
_parse_payamak_response_body,
_payamak_pattern_logical_success,
)
def test_normalize_phone_eleven_digits():
assert _normalize_phone_for_sms("09123456789") == "09123456789"
assert _normalize_phone_for_sms("9123456789") == "09123456789"
assert _normalize_phone_for_sms("+989123456789") == "09123456789"
assert _normalize_phone_for_sms("00989123456789") == "09123456789"
assert _normalize_phone_for_sms("invalid") is None
def test_parse_asmx_success():
xml = """<?xml version="1.0"?>
<SendByBaseNumberResponse>
<SendByBaseNumberResult>98765432101234567</SendByBaseNumberResult>
</SendByBaseNumberResponse>"""
data = _parse_payamak_response_body(xml)
ok, err = _payamak_pattern_logical_success(data)
assert ok is True
assert err is None
def test_parse_asmx_error():
xml = "<SendByBaseNumberResult>-4</SendByBaseNumberResult>"
data = _parse_payamak_response_body(xml)
ok, err = _payamak_pattern_logical_success(data)
assert ok is False
assert err is not None

View File

@ -0,0 +1,57 @@
"""تست‌های مدیریت Tenant."""
from __future__ import annotations
async def _create_tenant(client, slug="acme", name="Acme Inc"):
return await client.post("/api/v1/tenants", json={"name": name, "slug": slug})
async def test_create_tenant(client):
resp = await _create_tenant(client)
assert resp.status_code == 201
data = resp.json()
assert data["slug"] == "acme"
assert data["status"] == "active"
assert "id" in data
async def test_create_tenant_normalizes_slug(client):
resp = await _create_tenant(client, slug="Acme-Co")
assert resp.status_code == 201
assert resp.json()["slug"] == "acme-co"
async def test_slug_must_be_unique(client):
first = await _create_tenant(client, slug="dup")
assert first.status_code == 201
second = await _create_tenant(client, slug="dup")
assert second.status_code == 409
assert second.json()["error"]["code"] == "slug_taken"
async def test_get_and_list_tenants(client):
created = await _create_tenant(client, slug="listme")
tenant_id = created.json()["id"]
got = await client.get(f"/api/v1/tenants/{tenant_id}")
assert got.status_code == 200
assert got.json()["id"] == tenant_id
listed = await client.get("/api/v1/tenants")
assert listed.status_code == 200
body = listed.json()
assert body["meta"]["total_items"] >= 1
assert len(body["items"]) >= 1
async def test_suspend_and_activate(client):
created = await _create_tenant(client, slug="susp")
tenant_id = created.json()["id"]
suspended = await client.post(f"/api/v1/tenants/{tenant_id}/suspend")
assert suspended.status_code == 200
assert suspended.json()["status"] == "suspended"
activated = await client.post(f"/api/v1/tenants/{tenant_id}/activate")
assert activated.status_code == 200
assert activated.json()["status"] == "active"

Some files were not shown because too many files have changed in this diff Show More