feat: scaffold Django multi-tenant project with 5 of 9 apps
Phase 1 scaffolding: config/, core/, base models, AES-256-GCM phone encryption, enums mirror apps.tenant: Tenant + Domain (django-tenants) apps.org: 11 models (OrgUnit hierarchy, Staff, audit logs) apps.account: 4 models (UserAccount as AUTH_USER_MODEL, login/password tracking) apps.permission: 7 models (RBAC + overrides + datascope + append-only changelog) apps.region: 5 models (District, BusinessArea, MetroLine, MetroStation, School) All migrations generated, manage.py check passes
This commit is contained in:
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "core.apps.CoreConfig"
|
||||
7
core/apps.py
Normal file
7
core/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
verbose_name = "核心工具"
|
||||
17
core/cache.py
Normal file
17
core/cache.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
def get_redis_key(tenant_schema: str, module: str, key: str) -> str:
|
||||
return f"{tenant_schema}:{module}:{key}"
|
||||
|
||||
|
||||
def cache_get(tenant_schema: str, module: str, key: str, default=None):
|
||||
return cache.get(get_redis_key(tenant_schema, module, key), default)
|
||||
|
||||
|
||||
def cache_set(tenant_schema: str, module: str, key: str, value, timeout: int = 300) -> None:
|
||||
cache.set(get_redis_key(tenant_schema, module, key), value, timeout)
|
||||
|
||||
|
||||
def cache_delete(tenant_schema: str, module: str, key: str) -> None:
|
||||
cache.delete(get_redis_key(tenant_schema, module, key))
|
||||
57
core/encryption.py
Normal file
57
core/encryption.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""PII encryption per AGENTS.md §4.4: AES-256-GCM (NOT Fernet)."""
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class PhoneEncryption:
|
||||
"""Encrypt phone numbers with AES-256-GCM, index via SHA-256 hash, display masked.
|
||||
|
||||
Storage: phone_encrypted = base64(nonce || ciphertext || tag)
|
||||
Index: phone_hash = sha256(plaintext) — used for equality lookups
|
||||
Display: 138****1234
|
||||
"""
|
||||
|
||||
NONCE_LEN = 12
|
||||
|
||||
@staticmethod
|
||||
def _key() -> bytes:
|
||||
key_b64 = settings.PHONE_ENCRYPTION_KEY
|
||||
if not key_b64:
|
||||
raise RuntimeError(
|
||||
"PHONE_ENCRYPTION_KEY is not configured. "
|
||||
"Generate with: python -c 'import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())'"
|
||||
)
|
||||
key = base64.b64decode(key_b64)
|
||||
if len(key) != 32:
|
||||
raise RuntimeError("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes (AES-256).")
|
||||
return key
|
||||
|
||||
@classmethod
|
||||
def encrypt(cls, phone: str) -> str:
|
||||
if phone is None:
|
||||
raise ValueError("phone cannot be None")
|
||||
aesgcm = AESGCM(cls._key())
|
||||
nonce = os.urandom(cls.NONCE_LEN)
|
||||
ct = aesgcm.encrypt(nonce, phone.encode("utf-8"), None)
|
||||
return base64.b64encode(nonce + ct).decode("ascii")
|
||||
|
||||
@classmethod
|
||||
def decrypt(cls, ciphertext_b64: str) -> str:
|
||||
raw = base64.b64decode(ciphertext_b64)
|
||||
nonce, ct = raw[: cls.NONCE_LEN], raw[cls.NONCE_LEN :]
|
||||
aesgcm = AESGCM(cls._key())
|
||||
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def hash(phone: str) -> str:
|
||||
return hashlib.sha256(phone.encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def mask(phone: str) -> str:
|
||||
if not phone or len(phone) < 7:
|
||||
return "***"
|
||||
return phone[:3] + "****" + phone[-4:]
|
||||
776
core/enums.py
Normal file
776
core/enums.py
Normal file
@@ -0,0 +1,776 @@
|
||||
"""Python mirror of DATA_MODEL/ENUMS.md v2.2. All values are lower_snake_case.
|
||||
|
||||
Authority: ENUMS.md is the single source of truth. When ENUMS.md changes,
|
||||
update this file in the same commit. All models/serializers MUST import
|
||||
choices from here; never hardcode enum strings.
|
||||
"""
|
||||
from django.db import models
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 2. Public / 平台级 固定枚举
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TenantPlan(models.TextChoices):
|
||||
BASIC = "basic", "基础版"
|
||||
PROFESSIONAL = "professional", "专业版"
|
||||
ENTERPRISE = "enterprise", "企业版"
|
||||
|
||||
|
||||
class TenantStatus(models.TextChoices):
|
||||
CREATING = "creating", "创建中"
|
||||
ACTIVE = "active", "正常"
|
||||
SUSPENDED = "suspended", "已挂起"
|
||||
PENDING_DELETE = "pending_delete", "待删除"
|
||||
DELETED = "deleted", "已删除"
|
||||
FAILED = "failed", "创建/初始化失败"
|
||||
|
||||
|
||||
class TenantSuspendedReason(models.TextChoices):
|
||||
OVERDUE = "overdue", "欠费"
|
||||
VIOLATION = "violation", "违规"
|
||||
REQUESTED = "requested", "客户申请"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class PlatformAdminRole(models.TextChoices):
|
||||
SUPER_ADMIN = "super_admin", "超级管理员"
|
||||
OPS_OPERATOR = "ops_operator", "运营管理员"
|
||||
READ_ONLY_AUDITOR = "read_only_auditor", "只读审计员"
|
||||
|
||||
|
||||
class PlatformAuditResult(models.TextChoices):
|
||||
SUCCESS = "success", "成功"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
|
||||
class BackupScheduleFrequency(models.TextChoices):
|
||||
HOURLY = "hourly", "每小时"
|
||||
DAILY = "daily", "每日"
|
||||
WEEKLY = "weekly", "每周"
|
||||
|
||||
|
||||
class BackupStorageTarget(models.TextChoices):
|
||||
LOCAL = "local", "本地存储"
|
||||
S3 = "s3", "Amazon S3"
|
||||
R2 = "r2", "Cloudflare R2"
|
||||
GCS = "gcs", "Google Cloud Storage"
|
||||
|
||||
|
||||
class BackupTriggerType(models.TextChoices):
|
||||
AUTO = "auto", "自动触发"
|
||||
MANUAL = "manual", "手动触发"
|
||||
PRE_UPGRADE = "pre_upgrade", "升级前触发"
|
||||
PRE_RESTORE = "pre_restore", "恢复前触发"
|
||||
|
||||
|
||||
class BackupRecordStatus(models.TextChoices):
|
||||
PENDING = "pending", "待执行"
|
||||
IN_PROGRESS = "in_progress", "执行中"
|
||||
SUCCESS = "success", "成功"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
|
||||
class ExportTaskFormat(models.TextChoices):
|
||||
CSV = "csv", "CSV"
|
||||
JSON = "json", "JSON"
|
||||
SQL_DUMP = "sql_dump", "SQL 导出"
|
||||
|
||||
|
||||
class ExportTaskStatus(models.TextChoices):
|
||||
PENDING = "pending", "待执行"
|
||||
IN_PROGRESS = "in_progress", "执行中"
|
||||
SUCCESS = "success", "成功"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
|
||||
class UpgradeEventType(models.TextChoices):
|
||||
UPGRADE = "upgrade", "升级"
|
||||
ROLLBACK = "rollback", "回滚"
|
||||
|
||||
|
||||
class UpgradeType(models.TextChoices):
|
||||
APP = "app", "A类-应用升级"
|
||||
SCHEMA = "schema", "B类-数据库结构升级"
|
||||
FEATURE = "feature", "C类-功能开关升级"
|
||||
|
||||
|
||||
class UpgradeStrategy(models.TextChoices):
|
||||
FULL = "full", "全量发布"
|
||||
CANARY = "canary", "灰度发布"
|
||||
|
||||
|
||||
class UpgradeEventStatus(models.TextChoices):
|
||||
DRAFT = "draft", "草稿"
|
||||
PRE_CHECK = "pre_check", "预检查"
|
||||
PRE_BACKUP = "pre_backup", "预备份"
|
||||
BATCH_RUNNING = "batch_running", "批次执行中"
|
||||
BATCH_DONE = "batch_done", "批次完成"
|
||||
HALTED = "halted", "已暂停"
|
||||
SUCCEEDED = "succeeded", "已成功"
|
||||
FAILED = "failed", "失败"
|
||||
ROLLBACK_RUNNING = "rollback_running", "回滚中"
|
||||
ROLLED_BACK = "rolled_back", "已回滚"
|
||||
|
||||
|
||||
class UpgradeFailurePolicy(models.TextChoices):
|
||||
HALT_BATCH = "halt_batch", "失败即停止批次"
|
||||
CONTINUE = "continue", "失败继续"
|
||||
|
||||
|
||||
class ClientReleasePlatform(models.TextChoices):
|
||||
WIN32 = "win32", "Windows 客户端"
|
||||
|
||||
|
||||
class ClientReleaseArch(models.TextChoices):
|
||||
X64 = "x64", "x64 架构"
|
||||
ARM64 = "arm64", "ARM64 架构"
|
||||
|
||||
|
||||
class ClientReleaseType(models.TextChoices):
|
||||
NORMAL = "normal", "普通更新"
|
||||
FORCE = "force", "强制更新"
|
||||
|
||||
|
||||
class ClientReleaseStatus(models.TextChoices):
|
||||
DRAFT = "draft", "草稿"
|
||||
PUBLISHED = "published", "已发布"
|
||||
ARCHIVED = "archived", "已归档"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.1 login / account
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class UserAccountStatus(models.TextChoices):
|
||||
ACTIVE = "active", "启用"
|
||||
DISABLED = "disabled", "停用"
|
||||
LOCKED = "locked", "锁定"
|
||||
|
||||
|
||||
class LoginFailureReason(models.TextChoices):
|
||||
WRONG_PASSWORD = "wrong_password", "用户名或密码错误"
|
||||
WRONG_CAPTCHA = "wrong_captcha", "验证码错误"
|
||||
ACCOUNT_LOCKED = "account_locked", "账号锁定"
|
||||
ACCOUNT_DISABLED = "account_disabled", "账号停用"
|
||||
TENANT_NOT_FOUND = "tenant_not_found", "租户不存在"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.2 org
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class OrgUnitType(models.TextChoices):
|
||||
COMPANY = "company", "公司"
|
||||
DIVISION = "division", "事业部"
|
||||
REGION = "region", "大区"
|
||||
AREA = "area", "区域"
|
||||
DISTRICT = "district", "片区"
|
||||
STORE = "store", "门店"
|
||||
GROUP = "group", "店组"
|
||||
FUNCTIONAL = "functional", "职能部门"
|
||||
|
||||
|
||||
class OrgUnitAttribute(models.TextChoices):
|
||||
DIRECT = "direct", "直营"
|
||||
FRANCHISE = "franchise", "加盟"
|
||||
|
||||
|
||||
class StaffRole(models.TextChoices):
|
||||
AGENT = "agent", "经纪人"
|
||||
STORE_MANAGER = "store_manager", "店长"
|
||||
AREA_MANAGER = "area_manager", "区域经理"
|
||||
ADMIN = "admin", "系统管理员"
|
||||
OPERATOR = "operator", "运营/行政"
|
||||
SYSTEM = "system", "系统账号"
|
||||
|
||||
|
||||
class StaffStatus(models.TextChoices):
|
||||
ACTIVE = "active", "在职"
|
||||
PROBATION = "probation", "试用"
|
||||
RESIGNED = "resigned", "离职"
|
||||
FROZEN = "frozen", "冻结"
|
||||
|
||||
|
||||
class StaffGender(models.TextChoices):
|
||||
MALE = "male", "男"
|
||||
FEMALE = "female", "女"
|
||||
UNKNOWN = "unknown", "未知"
|
||||
|
||||
|
||||
class StaffIdType(models.TextChoices):
|
||||
ID_CARD = "id_card", "身份证"
|
||||
PASSPORT = "passport", "护照"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class StaffTransferType(models.TextChoices):
|
||||
ONBOARD = "onboard", "入职"
|
||||
TRANSFER = "transfer", "调动"
|
||||
RESIGN = "resign", "离职"
|
||||
REJOIN = "rejoin", "复职"
|
||||
SUPERVISOR_CHANGE = "supervisor_change", "上级变更"
|
||||
ROLE_CHANGE = "role_change", "角色变更"
|
||||
FREEZE = "freeze", "冻结账号"
|
||||
UNFREEZE = "unfreeze", "恢复账号"
|
||||
|
||||
|
||||
class StaffAccountPlatform(models.TextChoices):
|
||||
FONREY = "fonrey", "房睿主账号"
|
||||
ANJUKE_58 = "58anjuke", "58安居客"
|
||||
CNREIC = "cnreic", "中国网络经纪人"
|
||||
WECHAT_MP = "wechat_mp", "微信公众号"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.3 permission
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class PermissionModule(models.TextChoices):
|
||||
HOME = "home", "首页"
|
||||
PROPERTY = "property", "房源"
|
||||
NEW_HOUSE = "new_house", "新房"
|
||||
CLIENT = "client", "客源"
|
||||
TRANSACTION = "transaction", "交易"
|
||||
DATA = "data", "数据"
|
||||
MARKETING = "marketing", "营销"
|
||||
HR = "hr", "人事OA"
|
||||
CONTRACT = "contract", "合同"
|
||||
TRINET = "trinet", "三网"
|
||||
SYSTEM = "system", "系统"
|
||||
MOBILE = "mobile", "移动端"
|
||||
SMART_STORE = "smart_store", "智能门店"
|
||||
RECHARGE = "recharge", "在线充值"
|
||||
|
||||
|
||||
class PermissionValueType(models.TextChoices):
|
||||
BOOLEAN = "boolean", "开关型"
|
||||
SCOPE = "scope", "范围型"
|
||||
INTEGER = "integer", "数值型"
|
||||
|
||||
|
||||
class PermissionRoleCategory(models.TextChoices):
|
||||
AGENT = "agent", "置业顾问"
|
||||
STORE_MANAGER = "store_manager", "店管"
|
||||
DIRECTOR = "director", "总经"
|
||||
OPERATOR = "operator", "运营/行政"
|
||||
CUSTOM = "custom", "自定义"
|
||||
|
||||
|
||||
class PermissionScopeLevel(models.TextChoices):
|
||||
NONE = "none", "无"
|
||||
SELF = "self", "本人"
|
||||
GROUP = "group", "本组"
|
||||
STORE = "store", "本门店"
|
||||
AREA = "area", "本区域"
|
||||
REGION = "region", "本大区"
|
||||
COMPANY = "company", "全公司"
|
||||
|
||||
|
||||
class PermissionOverrideMode(models.TextChoices):
|
||||
REPLACE = "replace", "覆盖"
|
||||
RESTRICT = "restrict", "限制"
|
||||
GRANT = "grant", "授予"
|
||||
|
||||
|
||||
class PermissionDataScopeType(models.TextChoices):
|
||||
SELF = "self", "本人"
|
||||
GROUP = "group", "本组"
|
||||
STORE = "store", "本门店"
|
||||
AREA = "area", "本区域"
|
||||
REGION = "region", "本大区"
|
||||
COMPANY = "company", "全公司"
|
||||
CUSTOM_UNIT = "custom_unit", "自定义组织单元"
|
||||
|
||||
|
||||
class PermissionChangeTargetType(models.TextChoices):
|
||||
ROLE = "role", "角色"
|
||||
ROLE_PERMISSION = "role_permission", "角色权限"
|
||||
STAFF_ROLE = "staff_role", "员工角色"
|
||||
STAFF_OVERRIDE = "staff_override", "员工权限覆盖"
|
||||
STAFF_SCOPE = "staff_scope", "员工数据范围"
|
||||
|
||||
|
||||
class PermissionChangeAction(models.TextChoices):
|
||||
CREATE = "create", "创建"
|
||||
UPDATE = "update", "更新"
|
||||
DELETE = "delete", "删除"
|
||||
ASSIGN = "assign", "分配"
|
||||
REVOKE = "revoke", "撤销"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.4 complex
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class SchoolType(models.TextChoices):
|
||||
PRIMARY = "primary", "小学"
|
||||
MIDDLE = "middle", "初中"
|
||||
HIGH = "high", "高中"
|
||||
K9 = "k9", "九年一贯制"
|
||||
K12 = "k12", "十二年一贯制"
|
||||
|
||||
|
||||
class SchoolNature(models.TextChoices):
|
||||
PUBLIC = "public", "公立"
|
||||
PRIVATE = "private", "私立"
|
||||
INTERNATIONAL = "international", "国际"
|
||||
|
||||
|
||||
class SchoolLevel(models.TextChoices):
|
||||
NORMAL = "normal", "普通"
|
||||
KEY = "key", "重点"
|
||||
TOP = "top", "名校"
|
||||
|
||||
|
||||
class ComplexBuildingType(models.TextChoices):
|
||||
SLAB = "slab", "板楼"
|
||||
TOWER = "tower", "塔楼"
|
||||
SLAB_TOWER = "slab_tower", "板塔结合"
|
||||
|
||||
|
||||
class ComplexWaterType(models.TextChoices):
|
||||
CIVIL = "civil", "民水"
|
||||
COMMERCIAL = "commercial", "商水"
|
||||
|
||||
|
||||
class ComplexElectricityType(models.TextChoices):
|
||||
CIVIL = "civil", "民电"
|
||||
COMMERCIAL = "commercial", "商电"
|
||||
|
||||
|
||||
class SchoolZoneType(models.TextChoices):
|
||||
GUARANTEED = "guaranteed", "对口"
|
||||
REFERENCE = "reference", "参考"
|
||||
LOTTERY = "lottery", "摇号"
|
||||
|
||||
|
||||
class ComplexPhotoCategory(models.TextChoices):
|
||||
COMPLEX = "complex", "楼盘图"
|
||||
LAYOUT = "layout", "户型图"
|
||||
VR = "vr", "VR图"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.5 property
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class PropertyType(models.TextChoices):
|
||||
RESIDENTIAL = "residential", "住宅"
|
||||
VILLA = "villa", "别墅"
|
||||
COMMERCIAL_RESIDENTIAL = "commercial_residential", "商住"
|
||||
SHOP = "shop", "商铺"
|
||||
OFFICE = "office", "写字楼"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class PropertyStatus(models.TextChoices):
|
||||
FOR_SALE = "for_sale", "出售"
|
||||
FOR_RENT = "for_rent", "出租"
|
||||
FOR_SALE_RENT = "for_sale_rent", "租售"
|
||||
SUSPENDED = "suspended", "暂缓"
|
||||
SOLD_ELSEWHERE = "sold_elsewhere", "他售"
|
||||
RENTED_ELSEWHERE = "rented_elsewhere", "他租"
|
||||
SOLD = "sold", "成交"
|
||||
UNLISTED = "unlisted", "未挂牌"
|
||||
|
||||
|
||||
class PropertyAttribute(models.TextChoices):
|
||||
PUBLIC = "public", "公盘"
|
||||
PRIVATE = "private", "私盘"
|
||||
SPECIAL = "special", "特盘"
|
||||
SEALED = "sealed", "封盘"
|
||||
|
||||
|
||||
class PropertyOrientation(models.TextChoices):
|
||||
EAST = "east", "东"
|
||||
SOUTH = "south", "南"
|
||||
WEST = "west", "西"
|
||||
NORTH = "north", "北"
|
||||
SOUTHEAST = "southeast", "东南"
|
||||
NORTHEAST = "northeast", "东北"
|
||||
EAST_WEST = "east_west", "东西"
|
||||
SOUTH_NORTH = "south_north", "南北"
|
||||
NORTHWEST = "northwest", "西北"
|
||||
SOUTHWEST = "southwest", "西南"
|
||||
|
||||
|
||||
class PropertyDecoration(models.TextChoices):
|
||||
ROUGH = "rough", "毛坯"
|
||||
PLAIN = "plain", "清水"
|
||||
SIMPLE = "simple", "简装"
|
||||
MEDIUM = "medium", "中装"
|
||||
FINE = "fine", "精装"
|
||||
LUXURY = "luxury", "豪装"
|
||||
|
||||
|
||||
class PropertyHouseStatus(models.TextChoices):
|
||||
OWNER_OCCUPIED = "owner_occupied", "业主自住"
|
||||
VACANT = "vacant", "空置"
|
||||
TENANT_OCCUPIED = "tenant_occupied", "租客在住"
|
||||
UNKNOWN = "unknown", "未知"
|
||||
|
||||
|
||||
class PropertyViewingTime(models.TextChoices):
|
||||
ANYTIME = "anytime", "随时看房"
|
||||
BY_APPOINTMENT = "by_appointment", "预约看房"
|
||||
INCONVENIENT = "inconvenient", "不便看房"
|
||||
|
||||
|
||||
class PropertyGrade(models.TextChoices):
|
||||
A = "a", "A(急迫)"
|
||||
B = "b", "B(较强)"
|
||||
C = "c", "C(一般)"
|
||||
D = "d", "D(较弱)"
|
||||
|
||||
|
||||
class PropertyContactGender(models.TextChoices):
|
||||
MALE = "male", "先生"
|
||||
FEMALE = "female", "女士"
|
||||
|
||||
|
||||
class PropertyContactIdentity(models.TextChoices):
|
||||
OWNER = "owner", "业主"
|
||||
CONTACT = "contact", "联系人"
|
||||
SUBLETTER = "subletter", "转租人"
|
||||
TENANT = "tenant", "租客"
|
||||
AGENT = "agent", "代理人"
|
||||
CORPORATE = "corporate", "企业法人"
|
||||
|
||||
|
||||
class PropertyListingType(models.TextChoices):
|
||||
FOR_SALE = "for_sale", "出售挂牌"
|
||||
FOR_RENT = "for_rent", "出租挂牌"
|
||||
|
||||
|
||||
class PropertyListingHistoryStatus(models.TextChoices):
|
||||
ACTIVE = "active", "生效中"
|
||||
ENDED = "ended", "已结束"
|
||||
|
||||
|
||||
class PropertyFollowLogType(models.TextChoices):
|
||||
WRITTEN = "written", "手写跟进"
|
||||
MODIFIED = "modified", "修改跟进"
|
||||
SENSITIVE_OP = "sensitive_op", "敏感操作"
|
||||
SENSITIVE_VIEW = "sensitive_view", "敏感查看"
|
||||
OTHER = "other", "其他"
|
||||
SYSTEM = "system", "系统"
|
||||
|
||||
|
||||
class PropertyFollowAiTag(models.TextChoices):
|
||||
AI_FOR_SALE = "ai_for_sale", "AI判断可售"
|
||||
AI_NOT_FOR_SALE = "ai_not_for_sale", "AI判断不可售"
|
||||
|
||||
|
||||
class PropertyFollowAttachmentFileType(models.TextChoices):
|
||||
BMP = "bmp", "BMP"
|
||||
JPG = "jpg", "JPG"
|
||||
PNG = "png", "PNG"
|
||||
SVG = "svg", "SVG"
|
||||
GIF = "gif", "GIF"
|
||||
|
||||
|
||||
class PropertyKeyType(models.TextChoices):
|
||||
MECHANICAL = "mechanical", "机械钥匙"
|
||||
PASSWORD = "password", "密码钥匙"
|
||||
|
||||
|
||||
class PropertyCommissionOwnerType(models.TextChoices):
|
||||
OWNER = "owner", "产权人本人"
|
||||
AUTHORIZED_THIRD = "authorized_third", "授权第三方"
|
||||
|
||||
|
||||
class PropertyCommissionStatus(models.TextChoices):
|
||||
ACTIVE = "active", "有效"
|
||||
EXPIRED = "expired", "过期"
|
||||
CANCELLED = "cancelled", "取消"
|
||||
|
||||
|
||||
class PropertyCommissionAttachmentCategory(models.TextChoices):
|
||||
ID_CARD = "id_card", "身份证件"
|
||||
PROPERTY_CERT = "property_cert", "产权证明"
|
||||
COMMISSION_LETTER = "commission_letter", "委托书"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class PropertyFieldSurveyStatus(models.TextChoices):
|
||||
DRAFT = "draft", "草稿"
|
||||
SUBMITTED = "submitted", "已提交"
|
||||
|
||||
|
||||
class PropertySurveyPhotoCategory(models.TextChoices):
|
||||
LAYOUT = "layout", "户型图"
|
||||
LIVING_ROOM = "living_room", "客厅"
|
||||
DINING_ROOM = "dining_room", "餐厅"
|
||||
BEDROOM = "bedroom", "卧室"
|
||||
BATHROOM = "bathroom", "卫生间"
|
||||
KITCHEN = "kitchen", "厨房"
|
||||
ENTRANCE = "entrance", "入户"
|
||||
BALCONY = "balcony", "阳台"
|
||||
STUDY = "study", "书房"
|
||||
INDOOR_OTHER = "indoor_other", "室内其他"
|
||||
OUTDOOR = "outdoor", "室外"
|
||||
|
||||
|
||||
class PropertyPhotoCategory(models.TextChoices):
|
||||
COVER = "cover", "封面"
|
||||
ENTRANCE = "entrance", "入户"
|
||||
LIVING_ROOM = "living_room", "客厅"
|
||||
DINING_ROOM = "dining_room", "餐厅"
|
||||
BEDROOM = "bedroom", "卧室"
|
||||
BATHROOM = "bathroom", "卫生间"
|
||||
KITCHEN = "kitchen", "厨房"
|
||||
BALCONY = "balcony", "阳台"
|
||||
STUDY = "study", "书房"
|
||||
INDOOR_OTHER = "indoor_other", "室内其他"
|
||||
OUTDOOR = "outdoor", "室外"
|
||||
PANORAMA = "panorama", "全景"
|
||||
|
||||
|
||||
class PropertyAttachmentCategory(models.TextChoices):
|
||||
ID_CARD = "id_card", "身份证件"
|
||||
PROPERTY_CERT = "property_cert", "产权证明"
|
||||
COMMISSION_LETTER = "commission_letter", "委托书"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class PropertyNumberHolderApprovalStatus(models.TextChoices):
|
||||
PENDING = "pending", "待审批"
|
||||
APPROVED = "approved", "已通过"
|
||||
REJECTED = "rejected", "已驳回"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.6 client
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ClientType(models.TextChoices):
|
||||
PRIVATE = "private", "私客"
|
||||
PUBLIC = "public", "公客"
|
||||
TRANSACTED = "transacted", "成交客"
|
||||
|
||||
|
||||
class ClientStatus(models.TextChoices):
|
||||
BUYING = "buying", "求购"
|
||||
RENTING = "renting", "求租"
|
||||
BUY_OR_RENT = "buy_or_rent", "租购"
|
||||
SUSPENDED = "suspended", "暂缓"
|
||||
BOUGHT = "bought", "已购"
|
||||
RENTED_DONE = "rented_done", "已租"
|
||||
PUBLIC = "public", "公客"
|
||||
INVALID = "invalid", "无效"
|
||||
|
||||
|
||||
class ClientGrade(models.TextChoices):
|
||||
A = "A", "A(急迫)"
|
||||
B = "B", "B(较强)"
|
||||
C = "C", "C(一般)"
|
||||
D = "D", "D(较弱)"
|
||||
E = "E", "E(暂不关注)"
|
||||
|
||||
|
||||
class ClientPropertyUsage(models.TextChoices):
|
||||
RESIDENTIAL = "residential", "住宅"
|
||||
VILLA = "villa", "别墅"
|
||||
COMMERCIAL_RESIDENTIAL = "commercial_residential", "商住"
|
||||
SHOP = "shop", "商铺"
|
||||
OFFICE = "office", "写字楼"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class ClientBuyingPurpose(models.TextChoices):
|
||||
RIGID = "rigid", "刚需"
|
||||
INVESTMENT = "investment", "投资"
|
||||
SCHOOL_DISTRICT = "school_district", "学区"
|
||||
UPGRADE = "upgrade", "改善"
|
||||
COMMERCIAL = "commercial", "商用"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class ClientPaymentMethod(models.TextChoices):
|
||||
FULL = "full", "全额"
|
||||
MORTGAGE = "mortgage", "商业贷款"
|
||||
MORTGAGE_FUND = "mortgage_fund", "商贷+公积金"
|
||||
FUND = "fund", "公积金"
|
||||
|
||||
|
||||
class ClientPropertiesOwned(models.TextChoices):
|
||||
NONE = "none", "无"
|
||||
LOCAL_NONE = "local_none", "本地无/外地有"
|
||||
LOCAL_HAS = "local_has", "本地有"
|
||||
|
||||
|
||||
class ClientIdType(models.TextChoices):
|
||||
ID_CARD = "id_card", "身份证"
|
||||
PASSPORT = "passport", "护照"
|
||||
HK_MACAO = "hk_macao", "港澳通行证"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class ClientTransferToPublicType(models.TextChoices):
|
||||
MANUAL = "manual", "手动转公"
|
||||
AUTO = "auto", "自动转公"
|
||||
MARKETING_JUMP = "marketing_jump", "营销客跳公"
|
||||
RESOURCE_PUBLIC = "resource_public", "资料客素公"
|
||||
|
||||
|
||||
class ClientInvalidReason(models.TextChoices):
|
||||
INVALID_PHONE = "invalid_phone", "号码无效"
|
||||
PEER_AGENT = "peer_agent", "同行"
|
||||
AD = "ad", "广告推销"
|
||||
NO_INTENT = "no_intent", "无意向"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class ClientTransactedType(models.TextChoices):
|
||||
BOUGHT = "bought", "我购"
|
||||
RENTED = "rented", "我租"
|
||||
|
||||
|
||||
class ClientTransactedPropertyType(models.TextChoices):
|
||||
SECOND_HAND = "second_hand", "二手"
|
||||
NEW_HOUSE = "new_house", "新房"
|
||||
|
||||
|
||||
class ClientActivityLevel(models.TextChoices):
|
||||
NEW_MATCHED = "new_matched", "新配对"
|
||||
ACTIVE_7D = "active_7d", "7日活跃"
|
||||
ACTIVE_30D = "active_30d", "30日活跃"
|
||||
ACTIVE_90D = "active_90d", "90日活跃"
|
||||
EXPIRING = "expiring", "即将过期"
|
||||
FROZEN = "frozen", "暂缓中"
|
||||
INVALID = "invalid", "无效"
|
||||
|
||||
|
||||
class ClientContactGender(models.TextChoices):
|
||||
MALE = "male", "先生"
|
||||
FEMALE = "female", "女士"
|
||||
|
||||
|
||||
class ClientRequirementType(models.TextChoices):
|
||||
SECOND_HAND = "second_hand", "二手"
|
||||
NEW_HOUSE = "new_house", "新房"
|
||||
RENTAL = "rental", "租房"
|
||||
|
||||
|
||||
class ClientFloorPreference(models.TextChoices):
|
||||
NO_FIRST = "no_first", "不要一楼"
|
||||
LOW = "low", "低楼层"
|
||||
MID = "mid", "中楼层"
|
||||
HIGH = "high", "高楼层"
|
||||
NO_TOP = "no_top", "不要顶楼"
|
||||
|
||||
|
||||
class ClientOrientation(models.TextChoices):
|
||||
EAST = "east", "东"
|
||||
SOUTH = "south", "南"
|
||||
WEST = "west", "西"
|
||||
NORTH = "north", "北"
|
||||
|
||||
|
||||
class ClientDecoration(models.TextChoices):
|
||||
ROUGH = "rough", "毛坯"
|
||||
PLAIN = "plain", "清水"
|
||||
SIMPLE = "simple", "简装"
|
||||
MEDIUM = "medium", "中装"
|
||||
FINE = "fine", "精装"
|
||||
LUXURY = "luxury", "豪装"
|
||||
|
||||
|
||||
class ClientBuildingAgeRange(models.TextChoices):
|
||||
WITHIN_5Y = "within_5y", "5年内"
|
||||
Y5_10 = "5_10y", "5-10年"
|
||||
Y10_15 = "10_15y", "10-15年"
|
||||
Y15_20 = "15_20y", "15-20年"
|
||||
OVER_20Y = "over_20y", "20年以上"
|
||||
|
||||
|
||||
class ClientFollowLogType(models.TextChoices):
|
||||
WRITTEN = "written", "写入跟进"
|
||||
MODIFIED = "modified", "修改跟进"
|
||||
SENSITIVE_VIEW = "sensitive_view", "敏感查看"
|
||||
OTHER = "other", "其他"
|
||||
SYSTEM = "system", "系统"
|
||||
|
||||
|
||||
class ClientViewingType(models.TextChoices):
|
||||
APPOINTMENT = "appointment", "预约"
|
||||
VIEWING = "viewing", "带看"
|
||||
REVISIT = "revisit", "复看"
|
||||
EMPTY = "empty", "空看"
|
||||
|
||||
|
||||
class ClientViewingIntent(models.TextChoices):
|
||||
INTERESTED = "interested", "感兴趣"
|
||||
NOT_INTERESTED = "not_interested", "不感兴趣"
|
||||
NEGOTIATING = "negotiating", "谈判中"
|
||||
CANCELLED = "cancelled", "取消"
|
||||
|
||||
|
||||
class ClientPropertyMatchSource(models.TextChoices):
|
||||
RECORDED = "recorded", "录客配房"
|
||||
SYSTEM = "system", "系统配房"
|
||||
|
||||
|
||||
class ClientPropertyMatchGroup(models.TextChoices):
|
||||
QUALITY_LAYOUT = "quality_layout", "优质户型"
|
||||
PRICE_REDUCED = "price_reduced", "降价"
|
||||
HOT = "hot", "热门"
|
||||
NEWLY_LISTED = "newly_listed", "新上"
|
||||
|
||||
|
||||
class ClientPropertyMatchStatus(models.TextChoices):
|
||||
SUGGESTED = "suggested", "待推送"
|
||||
SHARED = "shared", "已分享"
|
||||
REJECTED = "rejected", "已反馈不合适"
|
||||
VIEWED = "viewed", "客户已查看"
|
||||
|
||||
|
||||
class ClientStatusLogChangeType(models.TextChoices):
|
||||
STATUS_CHANGE = "status_change", "改状态"
|
||||
GRADE_CHANGE = "grade_change", "改等级"
|
||||
TO_PUBLIC = "to_public", "转公客"
|
||||
TO_TRANSACTED = "to_transacted", "转成交"
|
||||
TO_INVALID = "to_invalid", "转无效"
|
||||
OWNER_CHANGE = "owner_change", "改归属人"
|
||||
SOURCE_CHANGE = "source_change", "改来源"
|
||||
MERGE = "merge", "合并客源"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 3.7 setting
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
class SettingValueType(models.TextChoices):
|
||||
BOOL = "bool", "布尔"
|
||||
INT = "int", "整数"
|
||||
STRING = "string", "字符串"
|
||||
ENUM = "enum", "枚举"
|
||||
|
||||
|
||||
class FieldRuleModule(models.TextChoices):
|
||||
PROPERTY = "property", "房源"
|
||||
CLIENT = "client", "客源"
|
||||
|
||||
|
||||
class FieldRuleEntityType(models.TextChoices):
|
||||
RESIDENTIAL = "residential", "住宅"
|
||||
VILLA = "villa", "别墅"
|
||||
COMMERCIAL_RESIDENTIAL = "commercial_residential", "商住"
|
||||
SHOP = "shop", "商铺"
|
||||
OFFICE = "office", "写字楼"
|
||||
OTHER = "other", "其他"
|
||||
|
||||
|
||||
class FieldRuleTradeStatus(models.TextChoices):
|
||||
SALE = "sale", "出售"
|
||||
RENT = "rent", "出租"
|
||||
SALE_RENT = "sale_rent", "租售"
|
||||
ALL = "all", "全部"
|
||||
|
||||
|
||||
class FieldRuleRequirement(models.TextChoices):
|
||||
REQUIRED = "required", "必填"
|
||||
OPTIONAL = "optional", "选填"
|
||||
HIDDEN = "hidden", "隐藏"
|
||||
23
core/htmx.py
Normal file
23
core/htmx.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def htmx_response(request, template: str, context: dict | None = None, status: int = 200) -> HttpResponse:
|
||||
html = render_to_string(template, context or {}, request=request)
|
||||
return HttpResponse(html, status=status)
|
||||
|
||||
|
||||
def htmx_trigger(response: HttpResponse, event: str, detail: dict | None = None) -> HttpResponse:
|
||||
import json
|
||||
|
||||
payload = response.headers.get("HX-Trigger")
|
||||
triggers = json.loads(payload) if payload else {}
|
||||
triggers[event] = detail or {}
|
||||
response.headers["HX-Trigger"] = json.dumps(triggers)
|
||||
return response
|
||||
|
||||
|
||||
def htmx_redirect(url: str) -> HttpResponse:
|
||||
response = HttpResponse(status=204)
|
||||
response.headers["HX-Redirect"] = url
|
||||
return response
|
||||
0
core/middleware/__init__.py
Normal file
0
core/middleware/__init__.py
Normal file
7
core/middleware/audit.py
Normal file
7
core/middleware/audit.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
class AuditMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
request.audit_actor = getattr(request, "user", None)
|
||||
return None
|
||||
15
core/models/__init__.py
Normal file
15
core/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from core.models.base import (
|
||||
ActiveManager,
|
||||
AuditedModel,
|
||||
SoftDeleteModel,
|
||||
TimeStampedModel,
|
||||
UUIDPrimaryKeyModel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActiveManager",
|
||||
"AuditedModel",
|
||||
"SoftDeleteModel",
|
||||
"TimeStampedModel",
|
||||
"UUIDPrimaryKeyModel",
|
||||
]
|
||||
71
core/models/base.py
Normal file
71
core/models/base.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class UUIDPrimaryKeyModel(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class TimeStampedModel(UUIDPrimaryKeyModel):
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class ActiveManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(deleted_at__isnull=True)
|
||||
|
||||
|
||||
class SoftDeleteModel(TimeStampedModel):
|
||||
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
objects = ActiveManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=["deleted_at"])
|
||||
|
||||
def hard_delete(self):
|
||||
super().delete()
|
||||
|
||||
def restore(self):
|
||||
self.deleted_at = None
|
||||
self.save(update_fields=["deleted_at"])
|
||||
|
||||
@property
|
||||
def is_deleted(self):
|
||||
return self.deleted_at is not None
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class AuditedModel(SoftDeleteModel):
|
||||
created_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(app_label)s_%(class)s_created",
|
||||
db_index=True,
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(app_label)s_%(class)s_updated",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
0
core/templatetags/__init__.py
Normal file
0
core/templatetags/__init__.py
Normal file
12
core/templatetags/heroicons.py
Normal file
12
core/templatetags/heroicons.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def heroicon(name: str, variant: str = "outline", css_class: str = "w-5 h-5") -> str:
|
||||
return mark_safe(
|
||||
f'<svg class="{css_class}" data-heroicon="{name}" data-variant="{variant}" '
|
||||
f'fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"></svg>'
|
||||
)
|
||||
Reference in New Issue
Block a user