From 9a7d06b34e78ae2e6c20aa3fa8f4ac6ca777f762 Mon Sep 17 00:00:00 2001 From: ishenwei Date: Wed, 29 Apr 2026 17:01:55 +0800 Subject: [PATCH] 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 --- .env.example | 38 + .gitignore | 56 ++ apps/__init__.py | 0 apps/account/__init__.py | 0 apps/account/apps.py | 7 + apps/account/migrations/0001_initial.py | 81 ++ apps/account/migrations/0002_initial.py | 50 ++ apps/account/migrations/__init__.py | 0 apps/account/models/__init__.py | 15 + apps/account/models/account.py | 151 ++++ apps/account/serializers.py | 0 apps/account/services/__init__.py | 0 apps/account/tasks.py | 0 apps/account/templates/account/.gitkeep | 0 apps/account/tests/__init__.py | 0 apps/account/urls.py | 5 + apps/account/views.py | 0 apps/client/__init__.py | 0 apps/client/apps.py | 7 + apps/client/migrations/__init__.py | 0 apps/client/models/__init__.py | 0 apps/complex/__init__.py | 0 apps/complex/apps.py | 7 + apps/complex/migrations/__init__.py | 0 apps/complex/models/__init__.py | 0 apps/org/__init__.py | 0 apps/org/apps.py | 7 + apps/org/migrations/0001_initial.py | 300 +++++++ apps/org/migrations/__init__.py | 0 apps/org/models/__init__.py | 26 + apps/org/models/org_unit.py | 57 ++ apps/org/models/staff.py | 138 ++++ apps/org/models/staff_logs.py | 164 ++++ apps/org/serializers.py | 0 apps/org/services/__init__.py | 0 apps/org/tasks.py | 0 apps/org/templates/org/.gitkeep | 0 apps/org/tests/__init__.py | 0 apps/org/urls.py | 5 + apps/org/views.py | 0 apps/permission/__init__.py | 0 apps/permission/apps.py | 7 + apps/permission/migrations/0001_initial.py | 249 ++++++ apps/permission/migrations/__init__.py | 0 apps/permission/models/__init__.py | 18 + apps/permission/models/permission_def.py | 46 ++ apps/permission/models/role.py | 91 ++ apps/permission/models/staff_perm.py | 200 +++++ apps/permission/serializers.py | 0 apps/permission/services/__init__.py | 0 apps/permission/tasks.py | 0 apps/permission/templates/permission/.gitkeep | 0 apps/permission/tests/__init__.py | 0 apps/permission/urls.py | 5 + apps/permission/views.py | 0 apps/property/__init__.py | 0 apps/property/apps.py | 7 + apps/property/migrations/__init__.py | 0 apps/property/models/__init__.py | 0 apps/region/__init__.py | 0 apps/region/apps.py | 7 + apps/region/migrations/0001_initial.py | 128 +++ apps/region/migrations/__init__.py | 0 apps/region/models/__init__.py | 15 + apps/region/models/region.py | 145 ++++ apps/region/serializers.py | 0 apps/region/services/__init__.py | 0 apps/region/tasks.py | 0 apps/region/templates/region/.gitkeep | 0 apps/region/tests/__init__.py | 0 apps/region/urls.py | 5 + apps/region/views.py | 0 apps/release/__init__.py | 0 apps/release/apps.py | 7 + apps/release/migrations/__init__.py | 0 apps/release/models/__init__.py | 0 apps/release/serializers.py | 0 apps/release/urls.py | 5 + apps/release/views.py | 0 apps/setting/__init__.py | 0 apps/setting/apps.py | 7 + apps/setting/migrations/__init__.py | 0 apps/setting/models/__init__.py | 0 apps/tenant/__init__.py | 0 apps/tenant/apps.py | 7 + apps/tenant/migrations/__init__.py | 0 apps/tenant/models.py | 13 + config/__init__.py | 0 config/asgi.py | 7 + config/settings/__init__.py | 0 config/settings/base.py | 192 +++++ config/settings/development.py | 7 + config/settings/production.py | 27 + config/settings/testing.py | 18 + config/urls.py | 3 + config/urls_public.py | 8 + config/wsgi.py | 7 + core/__init__.py | 1 + core/apps.py | 7 + core/cache.py | 17 + core/encryption.py | 57 ++ core/enums.py | 776 ++++++++++++++++++ core/htmx.py | 23 + core/middleware/__init__.py | 0 core/middleware/audit.py | 7 + core/models/__init__.py | 15 + core/models/base.py | 71 ++ core/templatetags/__init__.py | 0 core/templatetags/heroicons.py | 12 + manage.py | 20 + pyproject.toml | 20 + requirements/base.txt | 20 + requirements/development.txt | 13 + requirements/production.txt | 1 + shared/__init__.py | 0 shared/apps.py | 6 + 116 files changed, 3411 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 apps/__init__.py create mode 100644 apps/account/__init__.py create mode 100644 apps/account/apps.py create mode 100644 apps/account/migrations/0001_initial.py create mode 100644 apps/account/migrations/0002_initial.py create mode 100644 apps/account/migrations/__init__.py create mode 100644 apps/account/models/__init__.py create mode 100644 apps/account/models/account.py create mode 100644 apps/account/serializers.py create mode 100644 apps/account/services/__init__.py create mode 100644 apps/account/tasks.py create mode 100644 apps/account/templates/account/.gitkeep create mode 100644 apps/account/tests/__init__.py create mode 100644 apps/account/urls.py create mode 100644 apps/account/views.py create mode 100644 apps/client/__init__.py create mode 100644 apps/client/apps.py create mode 100644 apps/client/migrations/__init__.py create mode 100644 apps/client/models/__init__.py create mode 100644 apps/complex/__init__.py create mode 100644 apps/complex/apps.py create mode 100644 apps/complex/migrations/__init__.py create mode 100644 apps/complex/models/__init__.py create mode 100644 apps/org/__init__.py create mode 100644 apps/org/apps.py create mode 100644 apps/org/migrations/0001_initial.py create mode 100644 apps/org/migrations/__init__.py create mode 100644 apps/org/models/__init__.py create mode 100644 apps/org/models/org_unit.py create mode 100644 apps/org/models/staff.py create mode 100644 apps/org/models/staff_logs.py create mode 100644 apps/org/serializers.py create mode 100644 apps/org/services/__init__.py create mode 100644 apps/org/tasks.py create mode 100644 apps/org/templates/org/.gitkeep create mode 100644 apps/org/tests/__init__.py create mode 100644 apps/org/urls.py create mode 100644 apps/org/views.py create mode 100644 apps/permission/__init__.py create mode 100644 apps/permission/apps.py create mode 100644 apps/permission/migrations/0001_initial.py create mode 100644 apps/permission/migrations/__init__.py create mode 100644 apps/permission/models/__init__.py create mode 100644 apps/permission/models/permission_def.py create mode 100644 apps/permission/models/role.py create mode 100644 apps/permission/models/staff_perm.py create mode 100644 apps/permission/serializers.py create mode 100644 apps/permission/services/__init__.py create mode 100644 apps/permission/tasks.py create mode 100644 apps/permission/templates/permission/.gitkeep create mode 100644 apps/permission/tests/__init__.py create mode 100644 apps/permission/urls.py create mode 100644 apps/permission/views.py create mode 100644 apps/property/__init__.py create mode 100644 apps/property/apps.py create mode 100644 apps/property/migrations/__init__.py create mode 100644 apps/property/models/__init__.py create mode 100644 apps/region/__init__.py create mode 100644 apps/region/apps.py create mode 100644 apps/region/migrations/0001_initial.py create mode 100644 apps/region/migrations/__init__.py create mode 100644 apps/region/models/__init__.py create mode 100644 apps/region/models/region.py create mode 100644 apps/region/serializers.py create mode 100644 apps/region/services/__init__.py create mode 100644 apps/region/tasks.py create mode 100644 apps/region/templates/region/.gitkeep create mode 100644 apps/region/tests/__init__.py create mode 100644 apps/region/urls.py create mode 100644 apps/region/views.py create mode 100644 apps/release/__init__.py create mode 100644 apps/release/apps.py create mode 100644 apps/release/migrations/__init__.py create mode 100644 apps/release/models/__init__.py create mode 100644 apps/release/serializers.py create mode 100644 apps/release/urls.py create mode 100644 apps/release/views.py create mode 100644 apps/setting/__init__.py create mode 100644 apps/setting/apps.py create mode 100644 apps/setting/migrations/__init__.py create mode 100644 apps/setting/models/__init__.py create mode 100644 apps/tenant/__init__.py create mode 100644 apps/tenant/apps.py create mode 100644 apps/tenant/migrations/__init__.py create mode 100644 apps/tenant/models.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/development.py create mode 100644 config/settings/production.py create mode 100644 config/settings/testing.py create mode 100644 config/urls.py create mode 100644 config/urls_public.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/apps.py create mode 100644 core/cache.py create mode 100644 core/encryption.py create mode 100644 core/enums.py create mode 100644 core/htmx.py create mode 100644 core/middleware/__init__.py create mode 100644 core/middleware/audit.py create mode 100644 core/models/__init__.py create mode 100644 core/models/base.py create mode 100644 core/templatetags/__init__.py create mode 100644 core/templatetags/heroicons.py create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 requirements/base.txt create mode 100644 requirements/development.txt create mode 100644 requirements/production.txt create mode 100644 shared/__init__.py create mode 100644 shared/apps.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c58aeeb --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# ============================================================ +# Fonrey .env.example +# Copy to .env and fill real values. Never commit .env. +# Generate PHONE_ENCRYPTION_KEY: +# python -c 'import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())' +# Generate SECRET_KEY: +# python -c 'import secrets; print(secrets.token_urlsafe(50))' +# ============================================================ + +# ----- Django ----- +SECRET_KEY= +DEBUG=True +DJANGO_SETTINGS_MODULE=config.settings.development +ALLOWED_HOSTS=localhost,127.0.0.1 + +# ----- Database (PostgreSQL 16) ----- +DB_NAME=fonrey +DB_USER=fonrey +DB_PASSWORD=fonrey +DB_HOST=db +DB_PORT=5432 + +# ----- Redis (Cache + Sessions + Celery) ----- +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/1 + +# ----- Cloudflare R2 (S3-compatible) ----- +R2_ENDPOINT_URL=https://.r2.cloudflarestorage.com +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME=media +R2_CUSTOM_DOMAIN= + +# ----- Sentry (production only) ----- +SENTRY_DSN= + +# ----- PII Encryption (AES-256-GCM, 32 bytes base64) ----- +PHONE_ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..288ace4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ +build/ +dist/ + +# Environment +.env +.env.local +.env.*.local +.venv/ +venv/ + +# Sisyphus session state +.sisyphus/ + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ +staticfiles/ + +# Tailwind build output +static/css/output.css + +# Frontend +node_modules/ + +# OS / Editor +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# Testing / coverage +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml +.tox/ + +# Tooling caches +.ruff_cache/ +.mypy_cache/ + +# Generated artifacts +openapi.json diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/__init__.py b/apps/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/apps.py b/apps/account/apps.py new file mode 100644 index 0000000..e2afed6 --- /dev/null +++ b/apps/account/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.account" + label = "account" diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py new file mode 100644 index 0000000..eb46cef --- /dev/null +++ b/apps/account/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.16 on 2026-04-29 08:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='UserAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('username', models.CharField(max_length=30)), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('phone_enc', models.TextField(blank=True, help_text='AES-256-GCM ciphertext of phone (core.encryption.PhoneEncryption).', null=True)), + ('phone_hash', models.CharField(blank=True, max_length=64, null=True)), + ('is_tenant_admin', models.BooleanField(default=False)), + ('status', models.CharField(choices=[('active', '启用'), ('disabled', '停用'), ('locked', '锁定')], default='active', max_length=10)), + ('is_initial_password', models.BooleanField(default=True)), + ('locked_until', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'user_accounts', + }, + ), + migrations.CreateModel( + name='PasswordResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=86, unique=True)), + ('expires_at', models.DateTimeField()), + ('is_used', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reset_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'password_reset_tokens', + }, + ), + migrations.CreateModel( + name='PasswordHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password_hash', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_histories', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'password_histories', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='LoginAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=30)), + ('ip_address', models.GenericIPAddressField()), + ('user_agent', models.TextField(blank=True, null=True)), + ('success', models.BooleanField()), + ('failure_reason', models.CharField(blank=True, choices=[('wrong_password', '用户名或密码错误'), ('wrong_captcha', '验证码错误'), ('account_locked', '账号锁定'), ('account_disabled', '账号停用'), ('tenant_not_found', '租户不存在')], max_length=30, null=True)), + ('attempted_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'login_attempts', + 'indexes': [models.Index(fields=['username'], name='idx_login_attempts_username'), models.Index(fields=['ip_address'], name='idx_login_attempts_ip'), models.Index(fields=['-attempted_at'], name='idx_login_attempts_time'), models.Index(fields=['username', 'success', '-attempted_at'], name='idx_login_attempts_fail_check')], + }, + ), + ] diff --git a/apps/account/migrations/0002_initial.py b/apps/account/migrations/0002_initial.py new file mode 100644 index 0000000..d3d3194 --- /dev/null +++ b/apps/account/migrations/0002_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.16 on 2026-04-29 08:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('account', '0001_initial'), + ('org', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='useraccount', + name='staff', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='account', to='org.staff'), + ), + migrations.AddIndex( + model_name='passwordresettoken', + index=models.Index(fields=['user'], name='idx_pw_reset_tokens_user'), + ), + migrations.AddIndex( + model_name='passwordhistory', + index=models.Index(fields=['user', '-created_at'], name='idx_pw_histories_user'), + ), + migrations.AddIndex( + model_name='useraccount', + index=models.Index(fields=['status'], name='idx_user_accounts_status'), + ), + migrations.AddIndex( + model_name='useraccount', + index=models.Index(fields=['staff'], name='idx_user_accounts_staff'), + ), + migrations.AddConstraint( + model_name='useraccount', + constraint=models.UniqueConstraint(fields=('username',), name='uq_user_accounts_username'), + ), + migrations.AddConstraint( + model_name='useraccount', + constraint=models.UniqueConstraint(condition=models.Q(('email__isnull', False)), fields=('email',), name='uq_user_accounts_email'), + ), + migrations.AddConstraint( + model_name='useraccount', + constraint=models.UniqueConstraint(condition=models.Q(('phone_hash__isnull', False)), fields=('phone_hash',), name='uq_user_accounts_phone'), + ), + ] diff --git a/apps/account/migrations/__init__.py b/apps/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/models/__init__.py b/apps/account/models/__init__.py new file mode 100644 index 0000000..6691cba --- /dev/null +++ b/apps/account/models/__init__.py @@ -0,0 +1,15 @@ +from apps.account.models.account import ( + LoginAttempt, + PasswordHistory, + PasswordResetToken, + UserAccount, + UserAccountManager, +) + +__all__ = [ + "LoginAttempt", + "PasswordHistory", + "PasswordResetToken", + "UserAccount", + "UserAccountManager", +] diff --git a/apps/account/models/account.py b/apps/account/models/account.py new file mode 100644 index 0000000..7510d5b --- /dev/null +++ b/apps/account/models/account.py @@ -0,0 +1,151 @@ +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.db import models +from django.utils import timezone + +from core.enums import LoginFailureReason, UserAccountStatus + + +class UserAccountManager(BaseUserManager): + def create_user(self, username, password=None, **extra_fields): + if not username: + raise ValueError("username 不能为空") + user = self.model(username=username, **extra_fields) + if password: + user.set_password(password) + user.save(using=self._db) + return user + + +class UserAccount(AbstractBaseUser): + username = models.CharField(max_length=30) + email = models.EmailField(null=True, blank=True) + phone_enc = models.TextField( + null=True, + blank=True, + help_text="AES-256-GCM ciphertext of phone (core.encryption.PhoneEncryption).", + ) + phone_hash = models.CharField(max_length=64, null=True, blank=True) + staff = models.OneToOneField( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="account", + ) + is_tenant_admin = models.BooleanField(default=False) + status = models.CharField( + max_length=10, + choices=UserAccountStatus.choices, + default=UserAccountStatus.ACTIVE, + ) + is_initial_password = models.BooleanField(default=True) + locked_until = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_accounts", + ) + + USERNAME_FIELD = "username" + REQUIRED_FIELDS: list = [] + + objects = UserAccountManager() + + class Meta: + db_table = "user_accounts" + constraints = [ + models.UniqueConstraint(fields=["username"], name="uq_user_accounts_username"), + models.UniqueConstraint( + fields=["email"], + name="uq_user_accounts_email", + condition=models.Q(email__isnull=False), + ), + models.UniqueConstraint( + fields=["phone_hash"], + name="uq_user_accounts_phone", + condition=models.Q(phone_hash__isnull=False), + ), + ] + indexes = [ + models.Index(fields=["status"], name="idx_user_accounts_status"), + models.Index(fields=["staff"], name="idx_user_accounts_staff"), + ] + + def __str__(self) -> str: + kind = "admin" if self.is_tenant_admin else "staff" + return f"{self.username} ({kind})" + + def is_locked(self) -> bool: + if self.status != UserAccountStatus.LOCKED: + return False + if self.locked_until and timezone.now() >= self.locked_until: + return False + return True + + +class LoginAttempt(models.Model): + username = models.CharField(max_length=30) + ip_address = models.GenericIPAddressField() + user_agent = models.TextField(null=True, blank=True) + success = models.BooleanField() + failure_reason = models.CharField( + max_length=30, + null=True, + blank=True, + choices=LoginFailureReason.choices, + ) + attempted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "login_attempts" + indexes = [ + models.Index(fields=["username"], name="idx_login_attempts_username"), + models.Index(fields=["ip_address"], name="idx_login_attempts_ip"), + models.Index(fields=["-attempted_at"], name="idx_login_attempts_time"), + models.Index( + fields=["username", "success", "-attempted_at"], + name="idx_login_attempts_fail_check", + ), + ] + + +class PasswordResetToken(models.Model): + user = models.ForeignKey( + "account.UserAccount", + on_delete=models.CASCADE, + related_name="reset_tokens", + ) + token = models.CharField(max_length=86, unique=True) + expires_at = models.DateTimeField() + is_used = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "password_reset_tokens" + indexes = [ + models.Index(fields=["user"], name="idx_pw_reset_tokens_user"), + ] + + def is_valid(self) -> bool: + return not self.is_used and timezone.now() < self.expires_at + + +class PasswordHistory(models.Model): + user = models.ForeignKey( + "account.UserAccount", + on_delete=models.CASCADE, + related_name="password_histories", + ) + password_hash = models.CharField(max_length=128) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "password_histories" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "-created_at"], name="idx_pw_histories_user"), + ] diff --git a/apps/account/serializers.py b/apps/account/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/services/__init__.py b/apps/account/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/tasks.py b/apps/account/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/templates/account/.gitkeep b/apps/account/templates/account/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/tests/__init__.py b/apps/account/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/urls.py b/apps/account/urls.py new file mode 100644 index 0000000..485e8cf --- /dev/null +++ b/apps/account/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "account" + +urlpatterns: list = [] diff --git a/apps/account/views.py b/apps/account/views.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/__init__.py b/apps/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/apps.py b/apps/client/apps.py new file mode 100644 index 0000000..1ec880e --- /dev/null +++ b/apps/client/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ClientConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.client" + label = "fonrey_client" diff --git a/apps/client/migrations/__init__.py b/apps/client/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/client/models/__init__.py b/apps/client/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/__init__.py b/apps/complex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/apps.py b/apps/complex/apps.py new file mode 100644 index 0000000..8e97024 --- /dev/null +++ b/apps/complex/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ComplexConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.complex" + label = "fonrey_complex" diff --git a/apps/complex/migrations/__init__.py b/apps/complex/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/models/__init__.py b/apps/complex/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/__init__.py b/apps/org/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/apps.py b/apps/org/apps.py new file mode 100644 index 0000000..ac67c6e --- /dev/null +++ b/apps/org/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OrgConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.org" + label = "org" diff --git a/apps/org/migrations/0001_initial.py b/apps/org/migrations/0001_initial.py new file mode 100644 index 0000000..454bffb --- /dev/null +++ b/apps/org/migrations/0001_initial.py @@ -0,0 +1,300 @@ +# Generated by Django 4.2.16 on 2026-04-29 08:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OrgUnit', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(choices=[('company', '公司'), ('division', '事业部'), ('region', '大区'), ('area', '区域'), ('district', '片区'), ('store', '门店'), ('group', '店组'), ('functional', '职能部门')], max_length=20)), + ('path', models.TextField(help_text='Materialized path: /root_id/.../self_id/ for subtree queries.')), + ('depth', models.SmallIntegerField(default=0)), + ('sort_order', models.IntegerField(default=0)), + ('attribute', models.CharField(blank=True, choices=[('direct', '直营'), ('franchise', '加盟')], max_length=10, null=True)), + ('address_city', models.CharField(blank=True, default='', max_length=50)), + ('address_district', models.CharField(blank=True, default='', max_length=50)), + ('address_detail', models.CharField(blank=True, default='', max_length=200)), + ('latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('established_at', models.DateField(blank=True, null=True)), + ('phone', models.CharField(blank=True, default='', max_length=30)), + ('ext_start', models.IntegerField(blank=True, null=True)), + ('ext_end', models.IntegerField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'db_table': 'org_units', + 'ordering': ['sort_order', 'name'], + }, + ), + migrations.CreateModel( + name='Staff', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('name', models.CharField(max_length=50)), + ('nickname', models.CharField(blank=True, default='', max_length=50)), + ('employee_no', models.CharField(blank=True, max_length=30, null=True, unique=True)), + ('role', models.CharField(choices=[('agent', '经纪人'), ('store_manager', '店长'), ('area_manager', '区域经理'), ('admin', '系统管理员'), ('operator', '运营/行政'), ('system', '系统账号')], max_length=30)), + ('job_title', models.CharField(blank=True, default='', max_length=100)), + ('job_category', models.CharField(blank=True, default='', help_text="Job classification (e.g. '置业顾问' = agent qualification flag).", max_length=50)), + ('job_level', models.SmallIntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('active', '在职'), ('probation', '试用'), ('resigned', '离职'), ('frozen', '冻结')], default='active', max_length=20)), + ('phone_enc', models.BinaryField(blank=True, help_text='AES-256-GCM encrypted phone (DATA_MODEL_ORG §3.2).', null=True)), + ('phone_hash', models.CharField(blank=True, db_index=True, max_length=64, null=True)), + ('phone_hide', models.BooleanField(default=False)), + ('email', models.EmailField(blank=True, default='', max_length=255)), + ('extension', models.CharField(blank=True, default='', max_length=20)), + ('avatar_key', models.TextField(blank=True, default='')), + ('is_active', models.BooleanField(default=True)), + ('is_system_admin', models.BooleanField(default=False)), + ('first_joined_at', models.DateField(blank=True, null=True)), + ('rejoined_at', models.DateField(blank=True, null=True)), + ('resigned_at', models.DateField(blank=True, null=True)), + ('joined_count', models.SmallIntegerField(default=1)), + ('industry_exp_years', models.SmallIntegerField(blank=True, null=True)), + ('business_type', models.CharField(blank=True, default='', max_length=50)), + ('bank_name', models.CharField(blank=True, default='', max_length=100)), + ('bank_account', models.CharField(blank=True, default='', max_length=50)), + ('partner_no', models.CharField(blank=True, default='', max_length=50)), + ('recruit_source', models.CharField(blank=True, default='', max_length=50)), + ('mentor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mentees', to='org.staff')), + ('org_unit', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='staff_members', to='org.orgunit')), + ('recruit_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recruited_staff', to='org.staff')), + ('referrer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referred_staff', to='org.staff')), + ('supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subordinates', to='org.staff')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'staff', + }, + ), + migrations.CreateModel( + name='StaffWorkExperience', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company', models.CharField(max_length=200)), + ('job_title', models.CharField(blank=True, default='', max_length=100)), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('reason', models.CharField(blank=True, default='', max_length=200)), + ('reference_name', models.CharField(blank=True, default='', max_length=50)), + ('reference_phone', models.CharField(blank=True, default='', max_length=30)), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_experiences', to='org.staff')), + ], + options={ + 'db_table': 'staff_work_experiences', + }, + ), + migrations.CreateModel( + name='StaffTraining', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('training_name', models.CharField(max_length=200)), + ('training_date', models.DateField(blank=True, null=True)), + ('certificate', models.CharField(blank=True, default='', max_length=200)), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trainings', to='org.staff')), + ], + options={ + 'db_table': 'staff_trainings', + }, + ), + migrations.CreateModel( + name='StaffRewardPunish', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('rp_date', models.DateField()), + ('category', models.CharField(help_text='Configurable lookup_items domain: org.reward_punish_category.', max_length=50)), + ('name', models.CharField(max_length=100)), + ('remarks', models.TextField(blank=True, default='')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_reward_punish', to='org.staff')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reward_punish_records', to='org.staff')), + ], + options={ + 'db_table': 'staff_reward_punish', + }, + ), + migrations.CreateModel( + name='StaffRemark', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('content', models.TextField()), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_remarks', to='org.staff')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remarks', to='org.staff')), + ], + options={ + 'db_table': 'staff_remarks', + }, + ), + migrations.CreateModel( + name='StaffFamilyMember', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('relation', models.CharField(max_length=30)), + ('name', models.CharField(max_length=50)), + ('birthdate', models.DateField(blank=True, null=True)), + ('occupation', models.CharField(blank=True, default='', max_length=100)), + ('work_unit', models.CharField(blank=True, default='', max_length=200)), + ('phone_enc', models.BinaryField(blank=True, null=True)), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_members', to='org.staff')), + ], + options={ + 'db_table': 'staff_family_members', + }, + ), + migrations.CreateModel( + name='StaffEducation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('stage', models.CharField(blank=True, default='', max_length=30)), + ('school', models.CharField(max_length=200)), + ('major', models.CharField(blank=True, default='', max_length=100)), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('enrollment_status', models.CharField(blank=True, default='', max_length=30)), + ('degree', models.CharField(blank=True, default='', max_length=30)), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to='org.staff')), + ], + options={ + 'db_table': 'staff_educations', + }, + ), + migrations.CreateModel( + name='StaffAccount', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('platform', models.CharField(choices=[('fonrey', '房睿主账号'), ('58anjuke', '58安居客'), ('cnreic', '中国网络经纪人'), ('wechat_mp', '微信公众号')], max_length=30)), + ('account_no', models.CharField(blank=True, default='', max_length=100)), + ('is_real_name_match', models.BooleanField(blank=True, null=True)), + ('is_bound', models.BooleanField(default=False)), + ('bound_at', models.DateTimeField(blank=True, null=True)), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_accounts', to='org.staff')), + ], + options={ + 'db_table': 'staff_accounts', + }, + ), + migrations.AddField( + model_name='orgunit', + name='manager', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_org_units', to='org.staff'), + ), + migrations.AddField( + model_name='orgunit', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='children', to='org.orgunit'), + ), + migrations.CreateModel( + name='StaffTransferLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('transfer_type', models.CharField(choices=[('onboard', '入职'), ('transfer', '调动'), ('resign', '离职'), ('rejoin', '复职'), ('supervisor_change', '上级变更'), ('role_change', '角色变更'), ('freeze', '冻结账号'), ('unfreeze', '恢复账号')], max_length=30)), + ('old_value', models.JSONField(blank=True, null=True)), + ('new_value', models.JSONField(blank=True, null=True)), + ('transfer_date', models.DateField()), + ('remarks', models.CharField(blank=True, default='', max_length=50)), + ('operated_at', models.DateTimeField(auto_now_add=True)), + ('operator', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='operated_transfers', to='org.staff')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='transfer_logs', to='org.staff')), + ], + options={ + 'db_table': 'staff_transfer_logs', + 'indexes': [models.Index(fields=['staff', '-transfer_date'], name='idx_transfer_logs_staff'), models.Index(fields=['transfer_type', '-operated_at'], name='idx_transfer_logs_type'), models.Index(fields=['operator'], name='idx_transfer_logs_operator')], + }, + ), + migrations.CreateModel( + name='StaffPersonalInfo', + fields=[ + ('staff', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='personal_info', serialize=False, to='org.staff')), + ('gender', models.CharField(blank=True, choices=[('male', '男'), ('female', '女'), ('unknown', '未知')], default='', max_length=10)), + ('id_type', models.CharField(blank=True, choices=[('id_card', '身份证'), ('passport', '护照'), ('other', '其他')], default='', max_length=20)), + ('id_number_enc', models.BinaryField(blank=True, null=True)), + ('id_number_hash', models.CharField(blank=True, db_index=True, max_length=64, null=True)), + ('id_verified', models.BooleanField(default=False)), + ('id_verified_at', models.DateTimeField(blank=True, null=True)), + ('birthdate', models.DateField(blank=True, null=True)), + ('native_place', models.CharField(blank=True, default='', max_length=100)), + ('domicile_type', models.CharField(blank=True, default='', max_length=20)), + ('marital_status', models.CharField(blank=True, default='', max_length=20)), + ('political_status', models.CharField(blank=True, default='', max_length=20)), + ('has_children', models.BooleanField(blank=True, null=True)), + ('education_level', models.CharField(blank=True, default='', max_length=20)), + ('ethnicity', models.CharField(blank=True, default='', max_length=20)), + ('domicile_address', models.CharField(blank=True, default='', max_length=200)), + ('residence_address', models.CharField(blank=True, default='', max_length=200)), + ('work_start_date', models.DateField(blank=True, null=True)), + ('emergency_contact', models.CharField(blank=True, default='', max_length=50)), + ('emergency_phone_enc', models.BinaryField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_personal_info', to='org.staff')), + ], + options={ + 'db_table': 'staff_personal_info', + }, + ), + migrations.AddConstraint( + model_name='staffaccount', + constraint=models.UniqueConstraint(fields=('staff', 'platform'), name='uq_staff_accounts_staff_platform'), + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['org_unit'], name='idx_staff_org_unit'), + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['supervisor'], name='idx_staff_supervisor'), + ), + migrations.AddIndex( + model_name='staff', + index=models.Index(fields=['status'], name='idx_staff_status'), + ), + migrations.AddIndex( + model_name='orgunit', + index=models.Index(fields=['parent'], name='idx_org_units_parent'), + ), + migrations.AddIndex( + model_name='orgunit', + index=models.Index(fields=['type'], name='idx_org_units_type'), + ), + migrations.AddIndex( + model_name='orgunit', + index=models.Index(fields=['path'], name='idx_org_units_path'), + ), + ] diff --git a/apps/org/migrations/__init__.py b/apps/org/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/models/__init__.py b/apps/org/models/__init__.py new file mode 100644 index 0000000..7d23e33 --- /dev/null +++ b/apps/org/models/__init__.py @@ -0,0 +1,26 @@ +from apps.org.models.org_unit import OrgUnit +from apps.org.models.staff import Staff, StaffPersonalInfo +from apps.org.models.staff_logs import ( + StaffAccount, + StaffEducation, + StaffFamilyMember, + StaffRemark, + StaffRewardPunish, + StaffTraining, + StaffTransferLog, + StaffWorkExperience, +) + +__all__ = [ + "OrgUnit", + "Staff", + "StaffPersonalInfo", + "StaffAccount", + "StaffEducation", + "StaffFamilyMember", + "StaffRemark", + "StaffRewardPunish", + "StaffTraining", + "StaffTransferLog", + "StaffWorkExperience", +] diff --git a/apps/org/models/org_unit.py b/apps/org/models/org_unit.py new file mode 100644 index 0000000..4df9bf1 --- /dev/null +++ b/apps/org/models/org_unit.py @@ -0,0 +1,57 @@ +from django.db import models + +from core.enums import OrgUnitAttribute, OrgUnitType +from core.models.base import SoftDeleteModel + + +class OrgUnit(SoftDeleteModel): + name = models.CharField(max_length=100) + type = models.CharField(max_length=20, choices=OrgUnitType.choices) + parent = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.RESTRICT, + related_name="children", + db_index=True, + ) + path = models.TextField( + help_text="Materialized path: /root_id/.../self_id/ for subtree queries.", + ) + depth = models.SmallIntegerField(default=0) + sort_order = models.IntegerField(default=0) + attribute = models.CharField( + max_length=10, + choices=OrgUnitAttribute.choices, + null=True, + blank=True, + ) + address_city = models.CharField(max_length=50, blank=True, default="") + address_district = models.CharField(max_length=50, blank=True, default="") + address_detail = models.CharField(max_length=200, blank=True, default="") + latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + manager = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="managed_org_units", + ) + established_at = models.DateField(null=True, blank=True) + phone = models.CharField(max_length=30, blank=True, default="") + ext_start = models.IntegerField(null=True, blank=True) + ext_end = models.IntegerField(null=True, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "org_units" + indexes = [ + models.Index(fields=["parent"], name="idx_org_units_parent"), + models.Index(fields=["type"], name="idx_org_units_type"), + models.Index(fields=["path"], name="idx_org_units_path"), + ] + ordering = ["sort_order", "name"] + + def __str__(self) -> str: + return f"{self.name} ({self.type})" diff --git a/apps/org/models/staff.py b/apps/org/models/staff.py new file mode 100644 index 0000000..20335ae --- /dev/null +++ b/apps/org/models/staff.py @@ -0,0 +1,138 @@ +from django.conf import settings +from django.db import models + +from core.enums import StaffGender, StaffIdType, StaffRole, StaffStatus +from core.models.base import SoftDeleteModel + + +class Staff(SoftDeleteModel): + org_unit = models.ForeignKey( + "org.OrgUnit", + on_delete=models.RESTRICT, + related_name="staff_members", + db_index=True, + ) + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="staff_profile", + ) + name = models.CharField(max_length=50) + nickname = models.CharField(max_length=50, blank=True, default="") + employee_no = models.CharField(max_length=30, null=True, blank=True, unique=True) + role = models.CharField(max_length=30, choices=StaffRole.choices) + job_title = models.CharField(max_length=100, blank=True, default="") + job_category = models.CharField( + max_length=50, + blank=True, + default="", + help_text="Job classification (e.g. '置业顾问' = agent qualification flag).", + ) + job_level = models.SmallIntegerField(null=True, blank=True) + supervisor = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="subordinates", + ) + status = models.CharField( + max_length=20, + choices=StaffStatus.choices, + default=StaffStatus.ACTIVE, + ) + phone_enc = models.BinaryField( + null=True, + blank=True, + help_text="AES-256-GCM encrypted phone (DATA_MODEL_ORG §3.2).", + ) + phone_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True) + phone_hide = models.BooleanField(default=False) + email = models.EmailField(max_length=255, blank=True, default="") + extension = models.CharField(max_length=20, blank=True, default="") + avatar_key = models.TextField(blank=True, default="") + is_active = models.BooleanField(default=True) + is_system_admin = models.BooleanField(default=False) + first_joined_at = models.DateField(null=True, blank=True) + rejoined_at = models.DateField(null=True, blank=True) + resigned_at = models.DateField(null=True, blank=True) + joined_count = models.SmallIntegerField(default=1) + industry_exp_years = models.SmallIntegerField(null=True, blank=True) + mentor = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="mentees", + ) + business_type = models.CharField(max_length=50, blank=True, default="") + bank_name = models.CharField(max_length=100, blank=True, default="") + bank_account = models.CharField(max_length=50, blank=True, default="") + partner_no = models.CharField(max_length=50, blank=True, default="") + recruit_by = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="recruited_staff", + ) + recruit_source = models.CharField(max_length=50, blank=True, default="") + referrer = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="referred_staff", + ) + + class Meta: + db_table = "staff" + indexes = [ + models.Index(fields=["org_unit"], name="idx_staff_org_unit"), + models.Index(fields=["supervisor"], name="idx_staff_supervisor"), + models.Index(fields=["status"], name="idx_staff_status"), + ] + + def __str__(self) -> str: + return self.name + + +class StaffPersonalInfo(models.Model): + staff = models.OneToOneField( + "org.Staff", + on_delete=models.CASCADE, + related_name="personal_info", + primary_key=True, + ) + gender = models.CharField(max_length=10, choices=StaffGender.choices, blank=True, default="") + id_type = models.CharField(max_length=20, choices=StaffIdType.choices, blank=True, default="") + id_number_enc = models.BinaryField(null=True, blank=True) + id_number_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True) + id_verified = models.BooleanField(default=False) + id_verified_at = models.DateTimeField(null=True, blank=True) + birthdate = models.DateField(null=True, blank=True) + native_place = models.CharField(max_length=100, blank=True, default="") + domicile_type = models.CharField(max_length=20, blank=True, default="") + marital_status = models.CharField(max_length=20, blank=True, default="") + political_status = models.CharField(max_length=20, blank=True, default="") + has_children = models.BooleanField(null=True, blank=True) + education_level = models.CharField(max_length=20, blank=True, default="") + ethnicity = models.CharField(max_length=20, blank=True, default="") + domicile_address = models.CharField(max_length=200, blank=True, default="") + residence_address = models.CharField(max_length=200, blank=True, default="") + work_start_date = models.DateField(null=True, blank=True) + emergency_contact = models.CharField(max_length=50, blank=True, default="") + emergency_phone_enc = models.BinaryField(null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="updated_personal_info", + ) + + class Meta: + db_table = "staff_personal_info" diff --git a/apps/org/models/staff_logs.py b/apps/org/models/staff_logs.py new file mode 100644 index 0000000..13772cd --- /dev/null +++ b/apps/org/models/staff_logs.py @@ -0,0 +1,164 @@ +from django.db import models + +from core.enums import StaffAccountPlatform, StaffTransferType +from core.models.base import SoftDeleteModel, TimeStampedModel + + +class StaffTransferLog(TimeStampedModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.RESTRICT, + related_name="transfer_logs", + ) + transfer_type = models.CharField(max_length=30, choices=StaffTransferType.choices) + old_value = models.JSONField(null=True, blank=True) + new_value = models.JSONField(null=True, blank=True) + transfer_date = models.DateField() + remarks = models.CharField(max_length=50, blank=True, default="") + operator = models.ForeignKey( + "org.Staff", + on_delete=models.RESTRICT, + related_name="operated_transfers", + ) + operated_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "staff_transfer_logs" + indexes = [ + models.Index(fields=["staff", "-transfer_date"], name="idx_transfer_logs_staff"), + models.Index(fields=["transfer_type", "-operated_at"], name="idx_transfer_logs_type"), + models.Index(fields=["operator"], name="idx_transfer_logs_operator"), + ] + + +class StaffRewardPunish(SoftDeleteModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="reward_punish_records", + ) + rp_date = models.DateField() + category = models.CharField( + max_length=50, + help_text="Configurable lookup_items domain: org.reward_punish_category.", + ) + name = models.CharField(max_length=100) + remarks = models.TextField(blank=True, default="") + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_reward_punish", + ) + + class Meta: + db_table = "staff_reward_punish" + + +class StaffAccount(TimeStampedModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="external_accounts", + ) + platform = models.CharField(max_length=30, choices=StaffAccountPlatform.choices) + account_no = models.CharField(max_length=100, blank=True, default="") + is_real_name_match = models.BooleanField(null=True, blank=True) + is_bound = models.BooleanField(default=False) + bound_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "staff_accounts" + constraints = [ + models.UniqueConstraint( + fields=["staff", "platform"], + name="uq_staff_accounts_staff_platform", + ), + ] + + +class StaffWorkExperience(TimeStampedModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="work_experiences", + ) + company = models.CharField(max_length=200) + job_title = models.CharField(max_length=100, blank=True, default="") + start_date = models.DateField(null=True, blank=True) + end_date = models.DateField(null=True, blank=True) + reason = models.CharField(max_length=200, blank=True, default="") + reference_name = models.CharField(max_length=50, blank=True, default="") + reference_phone = models.CharField(max_length=30, blank=True, default="") + + class Meta: + db_table = "staff_work_experiences" + + +class StaffEducation(TimeStampedModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="educations", + ) + stage = models.CharField(max_length=30, blank=True, default="") + school = models.CharField(max_length=200) + major = models.CharField(max_length=100, blank=True, default="") + start_date = models.DateField(null=True, blank=True) + end_date = models.DateField(null=True, blank=True) + enrollment_status = models.CharField(max_length=30, blank=True, default="") + degree = models.CharField(max_length=30, blank=True, default="") + + class Meta: + db_table = "staff_educations" + + +class StaffTraining(TimeStampedModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="trainings", + ) + training_name = models.CharField(max_length=200) + training_date = models.DateField(null=True, blank=True) + certificate = models.CharField(max_length=200, blank=True, default="") + + class Meta: + db_table = "staff_trainings" + + +class StaffFamilyMember(TimeStampedModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="family_members", + ) + relation = models.CharField(max_length=30) + name = models.CharField(max_length=50) + birthdate = models.DateField(null=True, blank=True) + occupation = models.CharField(max_length=100, blank=True, default="") + work_unit = models.CharField(max_length=200, blank=True, default="") + phone_enc = models.BinaryField(null=True, blank=True) + + class Meta: + db_table = "staff_family_members" + + +class StaffRemark(SoftDeleteModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="remarks", + ) + content = models.TextField() + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_remarks", + ) + + class Meta: + db_table = "staff_remarks" diff --git a/apps/org/serializers.py b/apps/org/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/services/__init__.py b/apps/org/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/tasks.py b/apps/org/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/templates/org/.gitkeep b/apps/org/templates/org/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/tests/__init__.py b/apps/org/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/org/urls.py b/apps/org/urls.py new file mode 100644 index 0000000..32ebb5b --- /dev/null +++ b/apps/org/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "org" + +urlpatterns: list = [] diff --git a/apps/org/views.py b/apps/org/views.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/__init__.py b/apps/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/apps.py b/apps/permission/apps.py new file mode 100644 index 0000000..b069ea2 --- /dev/null +++ b/apps/permission/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PermissionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.permission" + label = "fonrey_permission" diff --git a/apps/permission/migrations/0001_initial.py b/apps/permission/migrations/0001_initial.py new file mode 100644 index 0000000..73daeb1 --- /dev/null +++ b/apps/permission/migrations/0001_initial.py @@ -0,0 +1,249 @@ +# Generated by Django 4.2.16 on 2026-04-29 08:47 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('org', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PermissionChangeLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('target_type', models.CharField(choices=[('role', '角色'), ('role_permission', '角色权限'), ('staff_role', '员工角色'), ('staff_override', '员工权限覆盖'), ('staff_scope', '员工数据范围')], max_length=30)), + ('target_id', models.UUIDField()), + ('permission_code', models.CharField(blank=True, default='', max_length=150)), + ('action', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除'), ('assign', '分配'), ('revoke', '撤销')], max_length=20)), + ('old_value', models.JSONField(blank=True, null=True)), + ('new_value', models.JSONField(blank=True, null=True)), + ('operator_ip', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True, default='')), + ('reason', models.TextField(blank=True, default='')), + ('operated_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'permission_change_logs', + 'ordering': ['-operated_at'], + }, + ), + migrations.CreateModel( + name='PermissionDef', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=150, unique=True)), + ('module', models.CharField(choices=[('home', '首页'), ('property', '房源'), ('new_house', '新房'), ('client', '客源'), ('transaction', '交易'), ('data', '数据'), ('marketing', '营销'), ('hr', '人事OA'), ('contract', '合同'), ('trinet', '三网'), ('system', '系统'), ('mobile', '移动端'), ('smart_store', '智能门店'), ('recharge', '在线充值')], max_length=50)), + ('sub_module', models.CharField(blank=True, default='', max_length=50)), + ('group_name', models.CharField(max_length=100)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True, default='')), + ('value_type', models.CharField(choices=[('boolean', '开关型'), ('scope', '范围型'), ('integer', '数值型')], max_length=20)), + ('scope_choices', models.JSONField(blank=True, default=list)), + ('integer_min', models.IntegerField(blank=True, null=True)), + ('integer_max', models.IntegerField(blank=True, null=True)), + ('default_value', models.JSONField(default=dict)), + ('max_allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), blank=True, default=list, size=None)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('is_deprecated', models.BooleanField(default=False)), + ('version', models.PositiveIntegerField(default=1)), + ], + options={ + 'db_table': 'permission_defs', + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('name', models.CharField(max_length=100)), + ('category', models.CharField(choices=[('agent', '置业顾问'), ('store_manager', '店管'), ('director', '总经'), ('operator', '运营/行政'), ('custom', '自定义')], max_length=30)), + ('description', models.TextField(blank=True, default='')), + ('is_system_builtin', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_roles_created', to='org.staff')), + ('template_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_roles', to='fonrey_permission.role')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_roles_updated', to='org.staff')), + ], + options={ + 'db_table': 'roles', + }, + ), + migrations.CreateModel( + name='StaffRole', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_primary', models.BooleanField(default=False)), + ('assigned_at', models.DateTimeField(auto_now_add=True)), + ('valid_from', models.DateField(blank=True, null=True)), + ('valid_until', models.DateField(blank=True, null=True)), + ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_role_assignments_made', to='org.staff')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='staff_links', to='fonrey_permission.role')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_roles', to='org.staff')), + ], + options={ + 'db_table': 'staff_roles', + }, + ), + migrations.CreateModel( + name='StaffPermissionOverride', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('value', models.JSONField()), + ('override_mode', models.CharField(choices=[('replace', '覆盖'), ('restrict', '限制'), ('grant', '授予')], default='replace', max_length=10)), + ('reason', models.TextField(blank=True, default='')), + ('modified_at', models.DateTimeField(auto_now=True)), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_overrides_modified', to='org.staff')), + ('permission_def', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='staff_overrides', to='fonrey_permission.permissiondef')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_overrides', to='org.staff')), + ], + options={ + 'db_table': 'staff_permission_overrides', + }, + ), + migrations.CreateModel( + name='StaffDataScope', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('scope_type', models.CharField(choices=[('self', '本人'), ('group', '本组'), ('store', '本门店'), ('area', '本区域'), ('region', '本大区'), ('company', '全公司'), ('custom_unit', '自定义组织单元')], max_length=20)), + ('is_readable', models.BooleanField(default=True)), + ('is_writable', models.BooleanField(default=False)), + ('granted_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('reason', models.TextField(blank=True, default='')), + ('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_scopes_granted', to='org.staff')), + ('org_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='data_scope_grants', to='org.orgunit')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='data_scopes', to='org.staff')), + ], + options={ + 'db_table': 'staff_data_scopes', + }, + ), + migrations.CreateModel( + name='RolePermission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('value', models.JSONField()), + ('permission_def', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='role_assignments', to='fonrey_permission.permissiondef')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='fonrey_permission.role')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='role_permissions_updated', to='org.staff')), + ], + options={ + 'db_table': 'role_permissions', + }, + ), + migrations.AddIndex( + model_name='permissiondef', + index=models.Index(condition=models.Q(('is_active', True)), fields=['module', 'sub_module', 'sort_order'], name='idx_perm_defs_module'), + ), + migrations.AddIndex( + model_name='permissiondef', + index=models.Index(condition=models.Q(('is_active', True)), fields=['is_active'], name='idx_perm_defs_active'), + ), + migrations.AddField( + model_name='permissionchangelog', + name='operator', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='permission_changes_operated', to='org.staff'), + ), + migrations.AddField( + model_name='permissionchangelog', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='change_logs', to='fonrey_permission.role'), + ), + migrations.AddField( + model_name='permissionchangelog', + name='staff', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_change_logs_affecting', to='org.staff'), + ), + migrations.AddIndex( + model_name='staffrole', + index=models.Index(fields=['role'], name='idx_staff_roles_role'), + ), + migrations.AddConstraint( + model_name='staffrole', + constraint=models.UniqueConstraint(fields=('staff', 'role'), name='uq_staff_roles'), + ), + migrations.AddConstraint( + model_name='staffrole', + constraint=models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('staff',), name='uq_staff_roles_primary'), + ), + migrations.AddIndex( + model_name='staffpermissionoverride', + index=models.Index(fields=['staff'], name='idx_staff_overrides_staff'), + ), + migrations.AddConstraint( + model_name='staffpermissionoverride', + constraint=models.UniqueConstraint(fields=('staff', 'permission_def'), name='uq_staff_overrides'), + ), + migrations.AddIndex( + model_name='staffdatascope', + index=models.Index(fields=['staff'], name='idx_data_scopes_staff'), + ), + migrations.AddIndex( + model_name='staffdatascope', + index=models.Index(fields=['org_unit'], name='idx_data_scopes_org'), + ), + migrations.AddIndex( + model_name='staffdatascope', + index=models.Index(condition=models.Q(('expires_at__isnull', False)), fields=['expires_at'], name='idx_data_scopes_expires'), + ), + migrations.AddIndex( + model_name='rolepermission', + index=models.Index(fields=['role'], name='idx_role_permissions_role'), + ), + migrations.AddIndex( + model_name='rolepermission', + index=models.Index(fields=['permission_def'], name='idx_role_permissions_def'), + ), + migrations.AddConstraint( + model_name='rolepermission', + constraint=models.UniqueConstraint(fields=('role', 'permission_def'), name='uq_role_permissions'), + ), + migrations.AddIndex( + model_name='role', + index=models.Index(condition=models.Q(('deleted_at__isnull', True)), fields=['category'], name='idx_roles_category'), + ), + migrations.AddIndex( + model_name='role', + index=models.Index(fields=['template_role'], name='idx_roles_template'), + ), + migrations.AddConstraint( + model_name='role', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('name',), name='uq_roles_name_active'), + ), + migrations.AddIndex( + model_name='permissionchangelog', + index=models.Index(condition=models.Q(('staff__isnull', False)), fields=['staff', '-operated_at'], name='idx_perm_log_staff'), + ), + migrations.AddIndex( + model_name='permissionchangelog', + index=models.Index(condition=models.Q(('role__isnull', False)), fields=['role', '-operated_at'], name='idx_perm_log_role'), + ), + migrations.AddIndex( + model_name='permissionchangelog', + index=models.Index(fields=['target_type', 'target_id', '-operated_at'], name='idx_perm_log_target'), + ), + migrations.AddIndex( + model_name='permissionchangelog', + index=models.Index(fields=['operator', '-operated_at'], name='idx_perm_log_operator'), + ), + migrations.AddIndex( + model_name='permissionchangelog', + index=models.Index(fields=['-operated_at'], name='idx_perm_log_time'), + ), + ] diff --git a/apps/permission/migrations/__init__.py b/apps/permission/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/models/__init__.py b/apps/permission/models/__init__.py new file mode 100644 index 0000000..52be7c8 --- /dev/null +++ b/apps/permission/models/__init__.py @@ -0,0 +1,18 @@ +from apps.permission.models.permission_def import PermissionDef +from apps.permission.models.role import Role, RolePermission +from apps.permission.models.staff_perm import ( + PermissionChangeLog, + StaffDataScope, + StaffPermissionOverride, + StaffRole, +) + +__all__ = [ + "PermissionChangeLog", + "PermissionDef", + "Role", + "RolePermission", + "StaffDataScope", + "StaffPermissionOverride", + "StaffRole", +] diff --git a/apps/permission/models/permission_def.py b/apps/permission/models/permission_def.py new file mode 100644 index 0000000..03b6844 --- /dev/null +++ b/apps/permission/models/permission_def.py @@ -0,0 +1,46 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + +from core.enums import PermissionModule, PermissionValueType +from core.models.base import TimeStampedModel + + +class PermissionDef(TimeStampedModel): + code = models.CharField(max_length=150, unique=True) + module = models.CharField(max_length=50, choices=PermissionModule.choices) + sub_module = models.CharField(max_length=50, blank=True, default="") + group_name = models.CharField(max_length=100) + name = models.CharField(max_length=200) + description = models.TextField(blank=True, default="") + value_type = models.CharField(max_length=20, choices=PermissionValueType.choices) + scope_choices = models.JSONField(default=list, blank=True) + integer_min = models.IntegerField(null=True, blank=True) + integer_max = models.IntegerField(null=True, blank=True) + default_value = models.JSONField(default=dict) + max_allowed_categories = ArrayField( + models.CharField(max_length=50), + default=list, + blank=True, + ) + sort_order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + is_deprecated = models.BooleanField(default=False) + version = models.PositiveIntegerField(default=1) + + class Meta: + db_table = "permission_defs" + indexes = [ + models.Index( + fields=["module", "sub_module", "sort_order"], + name="idx_perm_defs_module", + condition=models.Q(is_active=True), + ), + models.Index( + fields=["is_active"], + name="idx_perm_defs_active", + condition=models.Q(is_active=True), + ), + ] + + def __str__(self) -> str: + return f"{self.code} ({self.value_type})" diff --git a/apps/permission/models/role.py b/apps/permission/models/role.py new file mode 100644 index 0000000..629c9fa --- /dev/null +++ b/apps/permission/models/role.py @@ -0,0 +1,91 @@ +from django.db import models + +from core.enums import PermissionRoleCategory +from core.models.base import SoftDeleteModel, TimeStampedModel + + +class Role(SoftDeleteModel): + name = models.CharField(max_length=100) + category = models.CharField(max_length=30, choices=PermissionRoleCategory.choices) + description = models.TextField(blank=True, default="") + template_role = models.ForeignKey( + "fonrey_permission.Role", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="derived_roles", + ) + is_system_builtin = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="permission_roles_created", + ) + updated_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="permission_roles_updated", + ) + + class Meta: + db_table = "roles" + constraints = [ + models.UniqueConstraint( + fields=["name"], + name="uq_roles_name_active", + condition=models.Q(deleted_at__isnull=True), + ), + ] + indexes = [ + models.Index( + fields=["category"], + name="idx_roles_category", + condition=models.Q(deleted_at__isnull=True), + ), + models.Index(fields=["template_role"], name="idx_roles_template"), + ] + + def __str__(self) -> str: + return f"{self.name} ({self.category})" + + +class RolePermission(TimeStampedModel): + role = models.ForeignKey( + "fonrey_permission.Role", + on_delete=models.CASCADE, + related_name="permissions", + ) + permission_def = models.ForeignKey( + "fonrey_permission.PermissionDef", + on_delete=models.PROTECT, + related_name="role_assignments", + ) + value = models.JSONField() + updated_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="role_permissions_updated", + ) + + class Meta: + db_table = "role_permissions" + constraints = [ + models.UniqueConstraint( + fields=["role", "permission_def"], + name="uq_role_permissions", + ), + ] + indexes = [ + models.Index(fields=["role"], name="idx_role_permissions_role"), + models.Index(fields=["permission_def"], name="idx_role_permissions_def"), + ] + + def __str__(self) -> str: + return f"{self.role.name} → {self.permission_def.code}" diff --git a/apps/permission/models/staff_perm.py b/apps/permission/models/staff_perm.py new file mode 100644 index 0000000..c81fd0e --- /dev/null +++ b/apps/permission/models/staff_perm.py @@ -0,0 +1,200 @@ +from django.db import models + +from core.enums import ( + PermissionChangeAction, + PermissionChangeTargetType, + PermissionDataScopeType, + PermissionOverrideMode, +) +from core.models.base import TimeStampedModel, UUIDPrimaryKeyModel + + +class StaffRole(UUIDPrimaryKeyModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="staff_roles", + ) + role = models.ForeignKey( + "fonrey_permission.Role", + on_delete=models.PROTECT, + related_name="staff_links", + ) + is_primary = models.BooleanField(default=False) + assigned_at = models.DateTimeField(auto_now_add=True) + assigned_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="staff_role_assignments_made", + ) + valid_from = models.DateField(null=True, blank=True) + valid_until = models.DateField(null=True, blank=True) + + class Meta: + db_table = "staff_roles" + constraints = [ + models.UniqueConstraint( + fields=["staff", "role"], + name="uq_staff_roles", + ), + models.UniqueConstraint( + fields=["staff"], + condition=models.Q(is_primary=True), + name="uq_staff_roles_primary", + ), + ] + indexes = [ + models.Index(fields=["role"], name="idx_staff_roles_role"), + ] + + def __str__(self) -> str: + marker = " [primary]" if self.is_primary else "" + return f"{self.staff_id} → {self.role_id}{marker}" + + +class StaffPermissionOverride(UUIDPrimaryKeyModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="permission_overrides", + ) + permission_def = models.ForeignKey( + "fonrey_permission.PermissionDef", + on_delete=models.PROTECT, + related_name="staff_overrides", + ) + value = models.JSONField() + override_mode = models.CharField( + max_length=10, + choices=PermissionOverrideMode.choices, + default=PermissionOverrideMode.REPLACE, + ) + reason = models.TextField(blank=True, default="") + modified_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="staff_overrides_modified", + ) + modified_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "staff_permission_overrides" + constraints = [ + models.UniqueConstraint( + fields=["staff", "permission_def"], + name="uq_staff_overrides", + ), + ] + indexes = [ + models.Index(fields=["staff"], name="idx_staff_overrides_staff"), + ] + + +class StaffDataScope(UUIDPrimaryKeyModel): + staff = models.ForeignKey( + "org.Staff", + on_delete=models.CASCADE, + related_name="data_scopes", + ) + scope_type = models.CharField( + max_length=20, + choices=PermissionDataScopeType.choices, + ) + org_unit = models.ForeignKey( + "org.OrgUnit", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="data_scope_grants", + ) + is_readable = models.BooleanField(default=True) + is_writable = models.BooleanField(default=False) + granted_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="data_scopes_granted", + ) + granted_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(null=True, blank=True) + reason = models.TextField(blank=True, default="") + + class Meta: + db_table = "staff_data_scopes" + indexes = [ + models.Index(fields=["staff"], name="idx_data_scopes_staff"), + models.Index(fields=["org_unit"], name="idx_data_scopes_org"), + models.Index( + fields=["expires_at"], + name="idx_data_scopes_expires", + condition=models.Q(expires_at__isnull=False), + ), + ] + + +class PermissionChangeLog(UUIDPrimaryKeyModel): + target_type = models.CharField( + max_length=30, + choices=PermissionChangeTargetType.choices, + ) + target_id = models.UUIDField() + staff = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="permission_change_logs_affecting", + ) + role = models.ForeignKey( + "fonrey_permission.Role", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="change_logs", + ) + permission_code = models.CharField(max_length=150, blank=True, default="") + action = models.CharField(max_length=20, choices=PermissionChangeAction.choices) + old_value = models.JSONField(null=True, blank=True) + new_value = models.JSONField(null=True, blank=True) + operator = models.ForeignKey( + "org.Staff", + on_delete=models.PROTECT, + related_name="permission_changes_operated", + ) + operator_ip = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True, default="") + reason = models.TextField(blank=True, default="") + operated_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "permission_change_logs" + ordering = ["-operated_at"] + indexes = [ + models.Index( + fields=["staff", "-operated_at"], + name="idx_perm_log_staff", + condition=models.Q(staff__isnull=False), + ), + models.Index( + fields=["role", "-operated_at"], + name="idx_perm_log_role", + condition=models.Q(role__isnull=False), + ), + models.Index( + fields=["target_type", "target_id", "-operated_at"], + name="idx_perm_log_target", + ), + models.Index( + fields=["operator", "-operated_at"], + name="idx_perm_log_operator", + ), + models.Index(fields=["-operated_at"], name="idx_perm_log_time"), + ] + + def delete(self, *args, **kwargs): + raise NotImplementedError("PermissionChangeLog is append-only and cannot be deleted.") diff --git a/apps/permission/serializers.py b/apps/permission/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/services/__init__.py b/apps/permission/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/tasks.py b/apps/permission/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/templates/permission/.gitkeep b/apps/permission/templates/permission/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/tests/__init__.py b/apps/permission/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/permission/urls.py b/apps/permission/urls.py new file mode 100644 index 0000000..2f2b5bf --- /dev/null +++ b/apps/permission/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "permission" + +urlpatterns: list = [] diff --git a/apps/permission/views.py b/apps/permission/views.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/__init__.py b/apps/property/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/apps.py b/apps/property/apps.py new file mode 100644 index 0000000..7d04673 --- /dev/null +++ b/apps/property/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PropertyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.property" + label = "fonrey_property" diff --git a/apps/property/migrations/__init__.py b/apps/property/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/property/models/__init__.py b/apps/property/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/__init__.py b/apps/region/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/apps.py b/apps/region/apps.py new file mode 100644 index 0000000..4cbd8bf --- /dev/null +++ b/apps/region/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RegionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.region" + label = "region" diff --git a/apps/region/migrations/0001_initial.py b/apps/region/migrations/0001_initial.py new file mode 100644 index 0000000..e929c9b --- /dev/null +++ b/apps/region/migrations/0001_initial.py @@ -0,0 +1,128 @@ +# Generated by Django 4.2.16 on 2026-04-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='BusinessArea', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100)), + ('sort_order', models.IntegerField(default=0)), + ('latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'db_table': 'business_areas', + 'ordering': ['district_id', 'sort_order', 'name'], + }, + ), + migrations.CreateModel( + name='District', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('city', models.CharField(max_length=50)), + ('name', models.CharField(max_length=50)), + ('short_name', models.CharField(blank=True, default='', max_length=20)), + ('sort_order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'db_table': 'districts', + 'ordering': ['city', 'sort_order', 'name'], + }, + ), + migrations.CreateModel( + name='MetroLine', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('city', models.CharField(max_length=50)), + ('name', models.CharField(max_length=50)), + ('color', models.CharField(blank=True, default='', max_length=7)), + ('sort_order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'db_table': 'metro_lines', + 'ordering': ['city', 'sort_order', 'name'], + }, + ), + migrations.CreateModel( + name='School', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(blank=True, choices=[('primary', '小学'), ('middle', '初中'), ('high', '高中'), ('k9', '九年一贯制'), ('k12', '十二年一贯制')], default='', max_length=20)), + ('nature', models.CharField(blank=True, choices=[('public', '公立'), ('private', '私立'), ('international', '国际')], default='', max_length=20)), + ('level', models.CharField(blank=True, choices=[('normal', '普通'), ('key', '重点'), ('top', '名校')], default='', max_length=20)), + ('is_active', models.BooleanField(default=True)), + ('district', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schools', to='region.district')), + ], + options={ + 'db_table': 'schools', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='MetroStation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=50)), + ('latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('sort_order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('metro_line', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stations', to='region.metroline')), + ], + options={ + 'db_table': 'metro_stations', + 'ordering': ['metro_line_id', 'sort_order'], + }, + ), + migrations.AddConstraint( + model_name='district', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('city', 'name'), name='uq_districts_city_name'), + ), + migrations.AddField( + model_name='businessarea', + name='district', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='business_areas', to='region.district'), + ), + migrations.AddIndex( + model_name='school', + index=models.Index(condition=models.Q(('is_active', True)), fields=['district'], name='idx_schools_district'), + ), + migrations.AddIndex( + model_name='metrostation', + index=models.Index(condition=models.Q(('is_active', True)), fields=['metro_line'], name='idx_metro_stations_line'), + ), + migrations.AddIndex( + model_name='businessarea', + index=models.Index(condition=models.Q(('is_active', True)), fields=['district'], name='idx_business_areas_district'), + ), + migrations.AddConstraint( + model_name='businessarea', + constraint=models.UniqueConstraint(fields=('district', 'name'), name='uq_business_areas_name'), + ), + ] diff --git a/apps/region/migrations/__init__.py b/apps/region/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/models/__init__.py b/apps/region/models/__init__.py new file mode 100644 index 0000000..2067f08 --- /dev/null +++ b/apps/region/models/__init__.py @@ -0,0 +1,15 @@ +from apps.region.models.region import ( + BusinessArea, + District, + MetroLine, + MetroStation, + School, +) + +__all__ = [ + "BusinessArea", + "District", + "MetroLine", + "MetroStation", + "School", +] diff --git a/apps/region/models/region.py b/apps/region/models/region.py new file mode 100644 index 0000000..4085908 --- /dev/null +++ b/apps/region/models/region.py @@ -0,0 +1,145 @@ +from django.db import models + +from core.enums import SchoolLevel, SchoolNature, SchoolType +from core.models.base import TimeStampedModel + + +class District(TimeStampedModel): + city = models.CharField(max_length=50) + name = models.CharField(max_length=50) + short_name = models.CharField(max_length=20, blank=True, default="") + sort_order = models.IntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "districts" + constraints = [ + models.UniqueConstraint( + fields=["city", "name"], + condition=models.Q(is_active=True), + name="uq_districts_city_name", + ), + ] + ordering = ["city", "sort_order", "name"] + + def __str__(self) -> str: + return f"{self.city} / {self.name}" + + +class BusinessArea(TimeStampedModel): + district = models.ForeignKey( + "region.District", + on_delete=models.PROTECT, + related_name="business_areas", + ) + name = models.CharField(max_length=100) + sort_order = models.IntegerField(default=0) + latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "business_areas" + constraints = [ + models.UniqueConstraint( + fields=["district", "name"], + name="uq_business_areas_name", + ), + ] + indexes = [ + models.Index( + fields=["district"], + name="idx_business_areas_district", + condition=models.Q(is_active=True), + ), + ] + ordering = ["district_id", "sort_order", "name"] + + def __str__(self) -> str: + return self.name + + +class MetroLine(TimeStampedModel): + city = models.CharField(max_length=50) + name = models.CharField(max_length=50) + color = models.CharField(max_length=7, blank=True, default="") + sort_order = models.IntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "metro_lines" + ordering = ["city", "sort_order", "name"] + + def __str__(self) -> str: + return f"{self.city} {self.name}" + + +class MetroStation(TimeStampedModel): + metro_line = models.ForeignKey( + "region.MetroLine", + on_delete=models.CASCADE, + related_name="stations", + ) + name = models.CharField(max_length=50) + latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + sort_order = models.IntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "metro_stations" + indexes = [ + models.Index( + fields=["metro_line"], + name="idx_metro_stations_line", + condition=models.Q(is_active=True), + ), + ] + ordering = ["metro_line_id", "sort_order"] + + def __str__(self) -> str: + return self.name + + +class School(TimeStampedModel): + district = models.ForeignKey( + "region.District", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="schools", + ) + name = models.CharField(max_length=100) + type = models.CharField( + max_length=20, + blank=True, + default="", + choices=SchoolType.choices, + ) + nature = models.CharField( + max_length=20, + blank=True, + default="", + choices=SchoolNature.choices, + ) + level = models.CharField( + max_length=20, + blank=True, + default="", + choices=SchoolLevel.choices, + ) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "schools" + indexes = [ + models.Index( + fields=["district"], + name="idx_schools_district", + condition=models.Q(is_active=True), + ), + ] + ordering = ["name"] + + def __str__(self) -> str: + return self.name diff --git a/apps/region/serializers.py b/apps/region/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/services/__init__.py b/apps/region/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/tasks.py b/apps/region/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/templates/region/.gitkeep b/apps/region/templates/region/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/tests/__init__.py b/apps/region/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/region/urls.py b/apps/region/urls.py new file mode 100644 index 0000000..abaf4ad --- /dev/null +++ b/apps/region/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "region" + +urlpatterns: list = [] diff --git a/apps/region/views.py b/apps/region/views.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/__init__.py b/apps/release/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/apps.py b/apps/release/apps.py new file mode 100644 index 0000000..4a37600 --- /dev/null +++ b/apps/release/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReleaseConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.release" + label = "release" diff --git a/apps/release/migrations/__init__.py b/apps/release/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/models/__init__.py b/apps/release/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/serializers.py b/apps/release/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/release/urls.py b/apps/release/urls.py new file mode 100644 index 0000000..a49f6e7 --- /dev/null +++ b/apps/release/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "release" + +urlpatterns: list = [] diff --git a/apps/release/views.py b/apps/release/views.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/__init__.py b/apps/setting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/apps.py b/apps/setting/apps.py new file mode 100644 index 0000000..bfc243c --- /dev/null +++ b/apps/setting/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SettingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.setting" + label = "setting" diff --git a/apps/setting/migrations/__init__.py b/apps/setting/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/setting/models/__init__.py b/apps/setting/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/__init__.py b/apps/tenant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/apps.py b/apps/tenant/apps.py new file mode 100644 index 0000000..94e22b2 --- /dev/null +++ b/apps/tenant/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TenantConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.tenant" + label = "tenant" diff --git a/apps/tenant/migrations/__init__.py b/apps/tenant/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tenant/models.py b/apps/tenant/models.py new file mode 100644 index 0000000..db46b60 --- /dev/null +++ b/apps/tenant/models.py @@ -0,0 +1,13 @@ +from django.db import models +from django_tenants.models import DomainMixin, TenantMixin + + +class Tenant(TenantMixin): + name = models.CharField(max_length=255) + created_on = models.DateField(auto_now_add=True) + + auto_create_schema = True + + +class Domain(DomainMixin): + pass diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..bc33ab2 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +application = get_asgi_application() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..db77187 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,192 @@ +"""Fonrey base Django settings. Secrets via env (python-decouple). ASGI + django-tenants.""" +from pathlib import Path + +from decouple import Csv, config as env + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = env("SECRET_KEY") +DEBUG = env("DEBUG", default=False, cast=bool) +ALLOWED_HOSTS = env("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv()) + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +SHARED_APPS = [ + "django_tenants", # MUST be first per django-tenants + "apps.tenant", + "apps.release", + "shared", + "django.contrib.contenttypes", + "django.contrib.auth", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_celery_beat", + "django_celery_results", + "rest_framework", + "drf_spectacular", + "core", + "django_htmx", + "django_extensions", +] + +TENANT_APPS = [ + "apps.account", + "apps.permission", + "apps.org", + "apps.region", + "apps.complex", + "apps.property", + "apps.client", + "apps.setting", +] + +INSTALLED_APPS = list(SHARED_APPS) + [a for a in TENANT_APPS if a not in SHARED_APPS] + +TENANT_MODEL = "tenant.Tenant" +TENANT_DOMAIN_MODEL = "tenant.Domain" + +MIDDLEWARE = [ + "django_tenants.middleware.main.TenantMainMiddleware", # MUST be first + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_htmx.middleware.HtmxMiddleware", + "core.middleware.audit.AuditMiddleware", +] + +ROOT_URLCONF = "config.urls" +PUBLIC_SCHEMA_URLCONF = "config.urls_public" + +DATABASES = { + "default": { + "ENGINE": "django_tenants.postgresql_backend", + "NAME": env("DB_NAME"), + "USER": env("DB_USER"), + "PASSWORD": env("DB_PASSWORD"), + "HOST": env("DB_HOST", default="localhost"), + "PORT": env("DB_PORT", default="5432"), + "CONN_MAX_AGE": 60, + # Connection pooling lives at PgBouncer; no non-standard DSN keys here. + } +} + +DATABASE_ROUTERS = ["django_tenants.routers.TenantSyncRouter"] + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL", default="redis://127.0.0.1:6379/0"), + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + "KEY_PREFIX": "fonrey", + } +} +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" + +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://127.0.0.1:6379/1") +CELERY_RESULT_BACKEND = "django-db" +CELERY_TASK_ALWAYS_EAGER = False +CELERY_TASK_TIME_LIMIT = 300 +CELERY_TASK_SOFT_TIME_LIMIT = 270 +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "Asia/Shanghai" + +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +AWS_S3_ENDPOINT_URL = env("R2_ENDPOINT_URL", default="") +AWS_ACCESS_KEY_ID = env("R2_ACCESS_KEY_ID", default="") +AWS_SECRET_ACCESS_KEY = env("R2_SECRET_ACCESS_KEY", default="") +AWS_STORAGE_BUCKET_NAME = env("R2_BUCKET_NAME", default="media") +AWS_S3_CUSTOM_DOMAIN = env("R2_CUSTOM_DOMAIN", default="") or None +AWS_DEFAULT_ACL = "private" +AWS_S3_SIGNATURE_VERSION = "s3v4" +AWS_S3_FILE_OVERWRITE = False + +ASGI_APPLICATION = "config.asgi.application" +WSGI_APPLICATION = "config.wsgi.application" + +AUTH_USER_MODEL = "account.UserAccount" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +HTMX_GLOBAL_CSRF = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +LANGUAGE_CODE = "zh-hans" +TIME_ZONE = "Asia/Shanghai" +USE_I18N = True +USE_TZ = True +LOCALE_PATHS = [BASE_DIR / "locale"] + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +# CSRF_COOKIE_HTTPONLY MUST be False so HTMX can read the token from JS (spec §3.2). +CSRF_COOKIE_HTTPONLY = False +X_FRAME_OPTIONS = "DENY" + +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + ], +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Fonrey API", + "DESCRIPTION": "Fonrey 房产经纪管理系统 OpenAPI 3.1", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, + "ENUM_GENERATE_CHOICE_DESCRIPTION": True, +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "[{asctime}] {levelname} {name} ({process:d}) {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + } + }, + "root": {"handlers": ["console"], "level": "INFO"}, +} + +PHONE_ENCRYPTION_KEY = env("PHONE_ENCRYPTION_KEY", default="") diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..c32de63 --- /dev/null +++ b/config/settings/development.py @@ -0,0 +1,7 @@ +from .base import * # noqa: F401,F403 +from .base import INSTALLED_APPS, MIDDLEWARE + +INSTALLED_APPS = list(INSTALLED_APPS) + ["debug_toolbar"] +MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE] + +INTERNAL_IPS = ["127.0.0.1"] diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..0631477 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,27 @@ +import sentry_sdk +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.django import DjangoIntegration + +from .base import * # noqa: F401,F403 +from .base import env + +DEBUG = False + +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "same-origin" + +_sentry_dsn = env("SENTRY_DSN", default="") +if _sentry_dsn: + sentry_sdk.init( + dsn=_sentry_dsn, + integrations=[DjangoIntegration(), CeleryIntegration()], + traces_sample_rate=0.1, + send_default_pii=False, + environment=env("SENTRY_ENV", default="production"), + ) diff --git a/config/settings/testing.py b/config/settings/testing.py new file mode 100644 index 0000000..6c3bce4 --- /dev/null +++ b/config/settings/testing.py @@ -0,0 +1,18 @@ +from .base import * # noqa: F401,F403 + +DEBUG = False + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "fonrey-test", + } +} + +# Use synchronous in-memory session backend during tests; avoids Redis dependency. +SESSION_ENGINE = "django.contrib.sessions.backends.db" + +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..ab46257 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,3 @@ +from django.urls import path + +urlpatterns: list[path] = [] diff --git a/config/urls_public.py b/config/urls_public.py new file mode 100644 index 0000000..782501a --- /dev/null +++ b/config/urls_public.py @@ -0,0 +1,8 @@ +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +urlpatterns = [ + path("api/client/", include("apps.release.urls")), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..3eb3a0c --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..2da8e69 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +default_app_config = "core.apps.CoreConfig" diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..4eaba6d --- /dev/null +++ b/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" + verbose_name = "核心工具" diff --git a/core/cache.py b/core/cache.py new file mode 100644 index 0000000..3a71698 --- /dev/null +++ b/core/cache.py @@ -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)) diff --git a/core/encryption.py b/core/encryption.py new file mode 100644 index 0000000..f4ae497 --- /dev/null +++ b/core/encryption.py @@ -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:] diff --git a/core/enums.py b/core/enums.py new file mode 100644 index 0000000..1c5a87e --- /dev/null +++ b/core/enums.py @@ -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", "隐藏" diff --git a/core/htmx.py b/core/htmx.py new file mode 100644 index 0000000..4be94dc --- /dev/null +++ b/core/htmx.py @@ -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 diff --git a/core/middleware/__init__.py b/core/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/middleware/audit.py b/core/middleware/audit.py new file mode 100644 index 0000000..db87144 --- /dev/null +++ b/core/middleware/audit.py @@ -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 diff --git a/core/models/__init__.py b/core/models/__init__.py new file mode 100644 index 0000000..dcf3cdc --- /dev/null +++ b/core/models/__init__.py @@ -0,0 +1,15 @@ +from core.models.base import ( + ActiveManager, + AuditedModel, + SoftDeleteModel, + TimeStampedModel, + UUIDPrimaryKeyModel, +) + +__all__ = [ + "ActiveManager", + "AuditedModel", + "SoftDeleteModel", + "TimeStampedModel", + "UUIDPrimaryKeyModel", +] diff --git a/core/models/base.py b/core/models/base.py new file mode 100644 index 0000000..0f52552 --- /dev/null +++ b/core/models/base.py @@ -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 diff --git a/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/heroicons.py b/core/templatetags/heroicons.py new file mode 100644 index 0000000..2884e00 --- /dev/null +++ b/core/templatetags/heroicons.py @@ -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'' + ) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..a13257f --- /dev/null +++ b/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH? Did you forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..366c720 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] + +[tool.black] +line-length = 100 +target-version = ["py312"] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.testing" +python_files = ["test_*.py", "*_test.py"] +addopts = "--reuse-db --cov=apps --cov=core --cov-report=term-missing -n auto" diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..0f80b6a --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,20 @@ +Django==4.2.16 +django-tenants==3.7.0 +psycopg[binary]>=3.1.18,<3.3 +django-redis==5.4.0 +celery==5.4.0 +django-celery-beat==2.7.0 +django-celery-results==2.5.1 +django-storages[s3]==1.14.4 +boto3==1.35.0 +cryptography==43.0.0 +whitenoise==6.8.2 +gunicorn==23.0.0 +uvicorn[standard]==0.32.0 +sentry-sdk[django]==2.18.0 +python-decouple==3.8 +Pillow==11.0.0 +djangorestframework==3.15.2 +drf-spectacular==0.27.2 +django-htmx==1.21.0 +django-extensions==3.2.3 diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 0000000..e26673a --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,13 @@ +-r base.txt +ruff==0.7.0 +black==24.10.0 +pytest-django==4.9.0 +factory-boy==3.3.1 +pytest-mock==3.14.0 +responses==0.25.3 +pytest-cov==5.0.0 +pytest-xdist==3.6.1 +django-debug-toolbar==4.4.6 +schemathesis==3.36.0 +pytest-playwright==0.6.2 +playwright==1.49.1 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..a3e81b8 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1 @@ +-r base.txt diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/apps.py b/shared/apps.py new file mode 100644 index 0000000..5bfd568 --- /dev/null +++ b/shared/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SharedConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "shared"