feat: scaffold Django multi-tenant project with 5 of 9 apps
Phase 1 scaffolding: config/, core/, base models, AES-256-GCM phone encryption, enums mirror apps.tenant: Tenant + Domain (django-tenants) apps.org: 11 models (OrgUnit hierarchy, Staff, audit logs) apps.account: 4 models (UserAccount as AUTH_USER_MODEL, login/password tracking) apps.permission: 7 models (RBAC + overrides + datascope + append-only changelog) apps.region: 5 models (District, BusinessArea, MetroLine, MetroStation, School) All migrations generated, manage.py check passes
This commit is contained in:
38
.env.example
Normal file
38
.env.example
Normal file
@@ -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://<account_id>.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=
|
||||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -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
|
||||||
0
apps/__init__.py
Normal file
0
apps/__init__.py
Normal file
0
apps/account/__init__.py
Normal file
0
apps/account/__init__.py
Normal file
7
apps/account/apps.py
Normal file
7
apps/account/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.account"
|
||||||
|
label = "account"
|
||||||
81
apps/account/migrations/0001_initial.py
Normal file
81
apps/account/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
50
apps/account/migrations/0002_initial.py
Normal file
50
apps/account/migrations/0002_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/account/migrations/__init__.py
Normal file
0
apps/account/migrations/__init__.py
Normal file
15
apps/account/models/__init__.py
Normal file
15
apps/account/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from apps.account.models.account import (
|
||||||
|
LoginAttempt,
|
||||||
|
PasswordHistory,
|
||||||
|
PasswordResetToken,
|
||||||
|
UserAccount,
|
||||||
|
UserAccountManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LoginAttempt",
|
||||||
|
"PasswordHistory",
|
||||||
|
"PasswordResetToken",
|
||||||
|
"UserAccount",
|
||||||
|
"UserAccountManager",
|
||||||
|
]
|
||||||
151
apps/account/models/account.py
Normal file
151
apps/account/models/account.py
Normal file
@@ -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"),
|
||||||
|
]
|
||||||
0
apps/account/serializers.py
Normal file
0
apps/account/serializers.py
Normal file
0
apps/account/services/__init__.py
Normal file
0
apps/account/services/__init__.py
Normal file
0
apps/account/tasks.py
Normal file
0
apps/account/tasks.py
Normal file
0
apps/account/templates/account/.gitkeep
Normal file
0
apps/account/templates/account/.gitkeep
Normal file
0
apps/account/tests/__init__.py
Normal file
0
apps/account/tests/__init__.py
Normal file
5
apps/account/urls.py
Normal file
5
apps/account/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
app_name = "account"
|
||||||
|
|
||||||
|
urlpatterns: list = []
|
||||||
0
apps/account/views.py
Normal file
0
apps/account/views.py
Normal file
0
apps/client/__init__.py
Normal file
0
apps/client/__init__.py
Normal file
7
apps/client/apps.py
Normal file
7
apps/client/apps.py
Normal file
@@ -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"
|
||||||
0
apps/client/migrations/__init__.py
Normal file
0
apps/client/migrations/__init__.py
Normal file
0
apps/client/models/__init__.py
Normal file
0
apps/client/models/__init__.py
Normal file
0
apps/complex/__init__.py
Normal file
0
apps/complex/__init__.py
Normal file
7
apps/complex/apps.py
Normal file
7
apps/complex/apps.py
Normal file
@@ -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"
|
||||||
0
apps/complex/migrations/__init__.py
Normal file
0
apps/complex/migrations/__init__.py
Normal file
0
apps/complex/models/__init__.py
Normal file
0
apps/complex/models/__init__.py
Normal file
0
apps/org/__init__.py
Normal file
0
apps/org/__init__.py
Normal file
7
apps/org/apps.py
Normal file
7
apps/org/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OrgConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.org"
|
||||||
|
label = "org"
|
||||||
300
apps/org/migrations/0001_initial.py
Normal file
300
apps/org/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/org/migrations/__init__.py
Normal file
0
apps/org/migrations/__init__.py
Normal file
26
apps/org/models/__init__.py
Normal file
26
apps/org/models/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
57
apps/org/models/org_unit.py
Normal file
57
apps/org/models/org_unit.py
Normal file
@@ -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})"
|
||||||
138
apps/org/models/staff.py
Normal file
138
apps/org/models/staff.py
Normal file
@@ -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"
|
||||||
164
apps/org/models/staff_logs.py
Normal file
164
apps/org/models/staff_logs.py
Normal file
@@ -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"
|
||||||
0
apps/org/serializers.py
Normal file
0
apps/org/serializers.py
Normal file
0
apps/org/services/__init__.py
Normal file
0
apps/org/services/__init__.py
Normal file
0
apps/org/tasks.py
Normal file
0
apps/org/tasks.py
Normal file
0
apps/org/templates/org/.gitkeep
Normal file
0
apps/org/templates/org/.gitkeep
Normal file
0
apps/org/tests/__init__.py
Normal file
0
apps/org/tests/__init__.py
Normal file
5
apps/org/urls.py
Normal file
5
apps/org/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
app_name = "org"
|
||||||
|
|
||||||
|
urlpatterns: list = []
|
||||||
0
apps/org/views.py
Normal file
0
apps/org/views.py
Normal file
0
apps/permission/__init__.py
Normal file
0
apps/permission/__init__.py
Normal file
7
apps/permission/apps.py
Normal file
7
apps/permission/apps.py
Normal file
@@ -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"
|
||||||
249
apps/permission/migrations/0001_initial.py
Normal file
249
apps/permission/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/permission/migrations/__init__.py
Normal file
0
apps/permission/migrations/__init__.py
Normal file
18
apps/permission/models/__init__.py
Normal file
18
apps/permission/models/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
46
apps/permission/models/permission_def.py
Normal file
46
apps/permission/models/permission_def.py
Normal file
@@ -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})"
|
||||||
91
apps/permission/models/role.py
Normal file
91
apps/permission/models/role.py
Normal file
@@ -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}"
|
||||||
200
apps/permission/models/staff_perm.py
Normal file
200
apps/permission/models/staff_perm.py
Normal file
@@ -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.")
|
||||||
0
apps/permission/serializers.py
Normal file
0
apps/permission/serializers.py
Normal file
0
apps/permission/services/__init__.py
Normal file
0
apps/permission/services/__init__.py
Normal file
0
apps/permission/tasks.py
Normal file
0
apps/permission/tasks.py
Normal file
0
apps/permission/templates/permission/.gitkeep
Normal file
0
apps/permission/templates/permission/.gitkeep
Normal file
0
apps/permission/tests/__init__.py
Normal file
0
apps/permission/tests/__init__.py
Normal file
5
apps/permission/urls.py
Normal file
5
apps/permission/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
app_name = "permission"
|
||||||
|
|
||||||
|
urlpatterns: list = []
|
||||||
0
apps/permission/views.py
Normal file
0
apps/permission/views.py
Normal file
0
apps/property/__init__.py
Normal file
0
apps/property/__init__.py
Normal file
7
apps/property/apps.py
Normal file
7
apps/property/apps.py
Normal file
@@ -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"
|
||||||
0
apps/property/migrations/__init__.py
Normal file
0
apps/property/migrations/__init__.py
Normal file
0
apps/property/models/__init__.py
Normal file
0
apps/property/models/__init__.py
Normal file
0
apps/region/__init__.py
Normal file
0
apps/region/__init__.py
Normal file
7
apps/region/apps.py
Normal file
7
apps/region/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class RegionConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.region"
|
||||||
|
label = "region"
|
||||||
128
apps/region/migrations/0001_initial.py
Normal file
128
apps/region/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/region/migrations/__init__.py
Normal file
0
apps/region/migrations/__init__.py
Normal file
15
apps/region/models/__init__.py
Normal file
15
apps/region/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from apps.region.models.region import (
|
||||||
|
BusinessArea,
|
||||||
|
District,
|
||||||
|
MetroLine,
|
||||||
|
MetroStation,
|
||||||
|
School,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BusinessArea",
|
||||||
|
"District",
|
||||||
|
"MetroLine",
|
||||||
|
"MetroStation",
|
||||||
|
"School",
|
||||||
|
]
|
||||||
145
apps/region/models/region.py
Normal file
145
apps/region/models/region.py
Normal file
@@ -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
|
||||||
0
apps/region/serializers.py
Normal file
0
apps/region/serializers.py
Normal file
0
apps/region/services/__init__.py
Normal file
0
apps/region/services/__init__.py
Normal file
0
apps/region/tasks.py
Normal file
0
apps/region/tasks.py
Normal file
0
apps/region/templates/region/.gitkeep
Normal file
0
apps/region/templates/region/.gitkeep
Normal file
0
apps/region/tests/__init__.py
Normal file
0
apps/region/tests/__init__.py
Normal file
5
apps/region/urls.py
Normal file
5
apps/region/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
app_name = "region"
|
||||||
|
|
||||||
|
urlpatterns: list = []
|
||||||
0
apps/region/views.py
Normal file
0
apps/region/views.py
Normal file
0
apps/release/__init__.py
Normal file
0
apps/release/__init__.py
Normal file
7
apps/release/apps.py
Normal file
7
apps/release/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.release"
|
||||||
|
label = "release"
|
||||||
0
apps/release/migrations/__init__.py
Normal file
0
apps/release/migrations/__init__.py
Normal file
0
apps/release/models/__init__.py
Normal file
0
apps/release/models/__init__.py
Normal file
0
apps/release/serializers.py
Normal file
0
apps/release/serializers.py
Normal file
5
apps/release/urls.py
Normal file
5
apps/release/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
app_name = "release"
|
||||||
|
|
||||||
|
urlpatterns: list = []
|
||||||
0
apps/release/views.py
Normal file
0
apps/release/views.py
Normal file
0
apps/setting/__init__.py
Normal file
0
apps/setting/__init__.py
Normal file
7
apps/setting/apps.py
Normal file
7
apps/setting/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SettingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.setting"
|
||||||
|
label = "setting"
|
||||||
0
apps/setting/migrations/__init__.py
Normal file
0
apps/setting/migrations/__init__.py
Normal file
0
apps/setting/models/__init__.py
Normal file
0
apps/setting/models/__init__.py
Normal file
0
apps/tenant/__init__.py
Normal file
0
apps/tenant/__init__.py
Normal file
7
apps/tenant/apps.py
Normal file
7
apps/tenant/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.tenant"
|
||||||
|
label = "tenant"
|
||||||
0
apps/tenant/migrations/__init__.py
Normal file
0
apps/tenant/migrations/__init__.py
Normal file
13
apps/tenant/models.py
Normal file
13
apps/tenant/models.py
Normal file
@@ -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
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
7
config/asgi.py
Normal file
7
config/asgi.py
Normal file
@@ -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()
|
||||||
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
192
config/settings/base.py
Normal file
192
config/settings/base.py
Normal file
@@ -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="")
|
||||||
7
config/settings/development.py
Normal file
7
config/settings/development.py
Normal file
@@ -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"]
|
||||||
27
config/settings/production.py
Normal file
27
config/settings/production.py
Normal file
@@ -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"),
|
||||||
|
)
|
||||||
18
config/settings/testing.py
Normal file
18
config/settings/testing.py
Normal file
@@ -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"]
|
||||||
3
config/urls.py
Normal file
3
config/urls.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
urlpatterns: list[path] = []
|
||||||
8
config/urls_public.py
Normal file
8
config/urls_public.py
Normal file
@@ -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"),
|
||||||
|
]
|
||||||
7
config/wsgi.py
Normal file
7
config/wsgi.py
Normal file
@@ -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()
|
||||||
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "core.apps.CoreConfig"
|
||||||
7
core/apps.py
Normal file
7
core/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "core"
|
||||||
|
verbose_name = "核心工具"
|
||||||
17
core/cache.py
Normal file
17
core/cache.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis_key(tenant_schema: str, module: str, key: str) -> str:
|
||||||
|
return f"{tenant_schema}:{module}:{key}"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get(tenant_schema: str, module: str, key: str, default=None):
|
||||||
|
return cache.get(get_redis_key(tenant_schema, module, key), default)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(tenant_schema: str, module: str, key: str, value, timeout: int = 300) -> None:
|
||||||
|
cache.set(get_redis_key(tenant_schema, module, key), value, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_delete(tenant_schema: str, module: str, key: str) -> None:
|
||||||
|
cache.delete(get_redis_key(tenant_schema, module, key))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user