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:
2026-04-29 17:01:55 +08:00
parent 61535a53c2
commit 9a7d06b34e
116 changed files with 3411 additions and 0 deletions

1
core/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = "core.apps.CoreConfig"

7
core/apps.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

7
core/middleware/audit.py Normal file
View 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
View 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
View 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

View File

View 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>'
)