seed script

This commit is contained in:
javad committed 2026-09-10 16:39:02 +03:30
1 parent fa9d154db9
commit 08c30aa47f
15 files changed
+496 -17

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ venv
*.sqlite3-* *.sqlite3-*
media media
staticfiles staticfiles
production-seed
node_modules node_modules
graphify-out graphify-out
test-results test-results
+2
View File
@@ -21,7 +21,9 @@ DATABASE_WAIT_TIMEOUT=60
STATIC_VOLUME_NAME=customer_ticket_static STATIC_VOLUME_NAME=customer_ticket_static
MEDIA_VOLUME_NAME=customer_ticket_media MEDIA_VOLUME_NAME=customer_ticket_media
SEED_VOLUME_NAME=customer_ticket_seed
MEDIA_SIGNING_SECRET=replace-with-a-separate-random-secret-of-at-least-32-characters MEDIA_SIGNING_SECRET=replace-with-a-separate-random-secret-of-at-least-32-characters
REQUIRE_PRODUCTION_SEED=1
SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
SMS0098_USERNAME= SMS0098_USERNAME=
+1
View File
@@ -36,6 +36,7 @@ blob-report/
*.sqlite3-* *.sqlite3-*
media/ media/
staticfiles/ staticfiles/
production-seed/
*.log *.log
# Local environment and secrets; keep documented templates # Local environment and secrets; keep documented templates
+2 -1
View File
@@ -21,13 +21,14 @@ python -m venv .venv
Copy-Item .env.example .env Copy-Item .env.example .env
docker volume create customer_ticket_static docker volume create customer_ticket_static
docker volume create customer_ticket_media docker volume create customer_ticket_media
docker volume create customer_ticket_seed
docker compose up --build docker compose up --build
docker compose exec web python manage.py createsuperuser
``` ```
قبل از استقرار مقادیر secret و host در `.env` را تغییر دهید. TLS و proxy اپلیکیشن توسط Traefik خود Coolify مدیریت می‌شود. migration، bootstrap و collectstatic هنگام شروع web به شکل خودکار اجرا می‌شوند. Nginx فقط static را سرو می‌کند و فایل‌های آپلودی خصوصی پس از کنترل مجوز مستقیماً توسط Django تحویل داده می‌شوند. قبل از استقرار مقادیر secret و host در `.env` را تغییر دهید. TLS و proxy اپلیکیشن توسط Traefik خود Coolify مدیریت می‌شود. migration، bootstrap و collectstatic هنگام شروع web به شکل خودکار اجرا می‌شوند. Nginx فقط static را سرو می‌کند و فایل‌های آپلودی خصوصی پس از کنترل مجوز مستقیماً توسط Django تحویل داده می‌شوند.
راهنمای کامل تنظیم Coolify، volumeها، PostgreSQL خارجی و worker در [docs/DEPLOYMENT_COOLIFY.md](docs/DEPLOYMENT_COOLIFY.md) است. راهنمای کامل تنظیم Coolify، volumeها، PostgreSQL خارجی و worker در [docs/DEPLOYMENT_COOLIFY.md](docs/DEPLOYMENT_COOLIFY.md) است.
راهنمای انتقال خودکار تمام داده‌های پایدار SQLite به PostgreSQL در [docs/PRODUCTION_SEED.md](docs/PRODUCTION_SEED.md) است.
## تست ## تست
+16 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import PageTitle, SiteBranding from .models import PageTitle, ProductionSeedState, SiteBranding
@admin.register(SiteBranding) @admin.register(SiteBranding)
@@ -35,3 +35,18 @@ class PageTitleAdmin(admin.ModelAdmin):
list_editable = ("title", "is_active") list_editable = ("title", "is_active")
search_fields = ("route_name", "title") search_fields = ("route_name", "title")
list_filter = ("is_active",) list_filter = ("is_active",)
@admin.register(ProductionSeedState)
class ProductionSeedStateAdmin(admin.ModelAdmin):
list_display = ("bundle_sha256", "applied_at")
readonly_fields = ("bundle_sha256", "manifest", "applied_at")
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@@ -0,0 +1,98 @@
import gzip
import io
import json
import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from django.apps import apps
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from apps.core.production_seed import EXCLUDED_VOLATILE_MODELS, SEED_MODEL_LABELS, sha256_file
class Command(BaseCommand):
help = "Export durable SQLite data and media as a PostgreSQL-safe production seed bundle."
def add_arguments(self, parser):
parser.add_argument("--output", default="production-seed")
parser.add_argument("--force", action="store_true")
def handle(self, *args, **options):
if connection.vendor != "sqlite":
raise CommandError("Export must run against the source SQLite database.")
output_dir = Path(options["output"]).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
targets = [output_dir / name for name in ("data.json.gz", "media.tar.gz", "manifest.json")]
if any(path.exists() for path in targets) and not options["force"]:
raise CommandError(f"Seed files already exist in {output_dir}; pass --force to replace them.")
temporary_dir = output_dir / ".building"
if temporary_dir.exists():
shutil.rmtree(temporary_dir)
temporary_dir.mkdir()
try:
data_path = temporary_dir / "data.json.gz"
buffer = io.StringIO()
with transaction.atomic():
call_command(
"dumpdata",
*SEED_MODEL_LABELS,
format="json",
indent=2,
use_natural_foreign_keys=True,
use_natural_primary_keys=True,
stdout=buffer,
verbosity=0,
)
with gzip.open(data_path, "wt", encoding="utf-8") as stream:
stream.write(buffer.getvalue())
media_path = temporary_dir / "media.tar.gz"
media_root = Path(settings.MEDIA_ROOT)
media_files = []
with tarfile.open(media_path, "w:gz") as archive:
if media_root.exists():
for source in sorted(media_root.rglob("*")):
if source.is_file() and not source.is_symlink():
relative = source.relative_to(media_root).as_posix()
archive.add(source, arcname=relative, recursive=False)
media_files.append(relative)
counts = {
label: apps.get_model(label).objects.count()
for label in SEED_MODEL_LABELS
}
manifest = {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"source_database": "sqlite",
"models": counts,
"media_file_count": len(media_files),
"excluded_volatile_models": list(EXCLUDED_VOLATILE_MODELS),
"files": {
"data.json.gz": sha256_file(data_path),
"media.tar.gz": sha256_file(media_path),
},
}
manifest_path = temporary_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
for target in targets:
source = temporary_dir / target.name
source.replace(target)
finally:
shutil.rmtree(temporary_dir, ignore_errors=True)
self.stdout.write(self.style.SUCCESS(
f"Production seed exported to {output_dir} "
f"({sum(counts.values())} rows, {len(media_files)} media files)."
))
self.stdout.write(self.style.WARNING(
"This bundle contains personal data and password hashes. Keep it out of Git and transfer it securely."
))
@@ -0,0 +1,105 @@
import json
import shutil
import tarfile
import tempfile
from pathlib import Path
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from apps.accounts.models import User
from apps.core.models import ProductionSeedState
from apps.core.production_seed import sha256_file
class Command(BaseCommand):
help = "Import a verified production seed bundle into a pristine PostgreSQL database."
def add_arguments(self, parser):
parser.add_argument("bundle_dir", nargs="?", default="/app/production-seed")
def _load_manifest(self, bundle_dir):
manifest_path = bundle_dir / "manifest.json"
if not manifest_path.is_file():
raise CommandError(f"Missing seed manifest: {manifest_path}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("schema_version") != 1:
raise CommandError("Unsupported production seed schema version.")
for filename, expected in manifest.get("files", {}).items():
path = bundle_dir / filename
if not path.is_file() or sha256_file(path) != expected:
raise CommandError(f"Seed integrity check failed for {filename}.")
return manifest, sha256_file(manifest_path)
def _extract_media(self, archive_path, destination):
destination = destination.resolve()
with tarfile.open(archive_path, "r:gz") as archive:
for member in archive.getmembers():
member_path = Path(member.name)
if member.issym() or member.islnk() or member_path.is_absolute() or ".." in member_path.parts:
raise CommandError(f"Unsafe media archive member: {member.name}")
if member.isdir():
continue
if not member.isfile():
raise CommandError(f"Unsupported media archive member: {member.name}")
target = (destination / member_path).resolve()
if destination not in target.parents:
raise CommandError(f"Unsafe media archive path: {member.name}")
target.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
if source is None:
raise CommandError(f"Could not read media archive member: {member.name}")
with source, target.open("wb") as stream:
shutil.copyfileobj(source, stream)
def handle(self, *args, **options):
if connection.vendor != "postgresql":
raise CommandError("Production seed import is only allowed on PostgreSQL.")
bundle_dir = Path(options["bundle_dir"]).resolve()
manifest, bundle_sha256 = self._load_manifest(bundle_dir)
if ProductionSeedState.objects.filter(bundle_sha256=bundle_sha256).exists():
self.stdout.write(self.style.SUCCESS("Production seed is already applied; skipping."))
return
if ProductionSeedState.objects.exists() or User.objects.exists():
raise CommandError("Target database is not pristine; refusing to replace production data.")
media_root = Path(settings.MEDIA_ROOT).resolve()
if media_root.exists() and any(path.is_file() for path in media_root.rglob("*")):
raise CommandError("MEDIA_ROOT is not empty; refusing to overwrite production files.")
with tempfile.TemporaryDirectory(prefix="customer-ticket-seed-") as temporary:
staged_media = Path(temporary) / "media"
staged_media.mkdir()
self._extract_media(bundle_dir / "media.tar.gz", staged_media)
copied_files = []
try:
with transaction.atomic():
# Migrations contain defaults. Flush those rows before loading
# the authoritative SQLite snapshot; schema/migration history remains.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
media_root.mkdir(parents=True, exist_ok=True)
for source in sorted(staged_media.rglob("*")):
if not source.is_file():
continue
target = media_root / source.relative_to(staged_media)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
copied_files.append(target)
ProductionSeedState.objects.create(
bundle_sha256=bundle_sha256,
manifest=manifest,
)
except Exception:
for path in reversed(copied_files):
path.unlink(missing_ok=True)
raise
self.stdout.write(self.style.SUCCESS(
f"Production seed applied: {sum(manifest['models'].values())} rows and "
f"{manifest['media_file_count']} media files."
))
@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0005_sitebranding_company_logo_and_more")]
operations = [
migrations.CreateModel(
name="ProductionSeedState",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("bundle_sha256", models.CharField(max_length=64, unique=True)),
("manifest", models.JSONField(default=dict)),
("applied_at", models.DateTimeField(auto_now_add=True)),
],
options={
"verbose_name": "وضعیت بذر Production",
"verbose_name_plural": "وضعیت بذرهای Production",
"ordering": ("-applied_at",),
},
),
]
+16
View File
@@ -125,3 +125,19 @@ class PageTitle(models.Model):
def __str__(self): def __str__(self):
return f"{self.route_name}: {self.title}" return f"{self.route_name}: {self.title}"
class ProductionSeedState(models.Model):
"""Audit marker that makes production seed imports idempotent."""
bundle_sha256 = models.CharField(max_length=64, unique=True)
manifest = models.JSONField(default=dict)
applied_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-applied_at",)
verbose_name = "وضعیت بذر Production"
verbose_name_plural = "وضعیت بذرهای Production"
def __str__(self):
return f"{self.bundle_sha256[:12]} @ {self.applied_at}"
+48
View File
@@ -0,0 +1,48 @@
import hashlib
from pathlib import Path
SEED_MODEL_LABELS = (
"core.SiteBranding",
"core.PageTitle",
"auth.Group",
"accounts.User",
"staff.OrganizationalUnit",
"customers.ContactGroup",
"customers.CustomerProfile",
"staff.UnitMembership",
"staff.StaffProfile",
"tickets.TicketCategory",
"tickets.SLAConfig",
"tickets.TicketTag",
"tickets.GroupDispatch",
"tickets.Ticket",
"tickets.TicketAccessGrant",
"tickets.TicketEvent",
"messaging.Message",
"messaging.MessageRevision",
"messaging.MessageMention",
"messaging.MessageReadReceipt",
"tickets.TicketReadState",
"attachments.AttachmentPolicy",
"attachments.Attachment",
"notifications.SMSTemplate",
"notifications.NotificationPolicy",
)
EXCLUDED_VOLATILE_MODELS = (
"admin.LogEntry",
"sessions.Session",
"accounts.OTPChallenge",
"accounts.TicketMagicLink",
"accounts.MagicLinkAccessLog",
"notifications.NotificationJob",
)
def sha256_file(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
+6
View File
@@ -22,6 +22,8 @@ x-django-service: &django-service
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-10} DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-10}
DB_SSLMODE: ${DB_SSLMODE:-prefer} DB_SSLMODE: ${DB_SSLMODE:-prefer}
DATABASE_WAIT_TIMEOUT: ${DATABASE_WAIT_TIMEOUT:-60} DATABASE_WAIT_TIMEOUT: ${DATABASE_WAIT_TIMEOUT:-60}
PRODUCTION_SEED_DIR: /app/production-seed
REQUIRE_PRODUCTION_SEED: ${REQUIRE_PRODUCTION_SEED:-1}
MEDIA_ROOT: /app/media MEDIA_ROOT: /app/media
MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify} MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify}
USE_NGINX_MEDIA: "true" USE_NGINX_MEDIA: "true"
@@ -58,6 +60,7 @@ services:
volumes: volumes:
- static_data:/app/staticfiles - static_data:/app/staticfiles
- media_data:/app/media - media_data:/app/media
- seed_data:/app/production-seed:ro
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import http.client,os; c=http.client.HTTPConnection('127.0.0.1',8000,timeout=5); c.request('GET','/healthz/',headers={'Host':os.getenv('HEALTHCHECK_HOST','localhost'),'X-Forwarded-Proto':'https'}); r=c.getresponse(); raise SystemExit(0 if r.status==200 else 1)"] test: ["CMD", "python", "-c", "import http.client,os; c=http.client.HTTPConnection('127.0.0.1',8000,timeout=5); c.request('GET','/healthz/',headers={'Host':os.getenv('HEALTHCHECK_HOST','localhost'),'X-Forwarded-Proto':'https'}); r=c.getresponse(); raise SystemExit(0 if r.status==200 else 1)"]
interval: 15s interval: 15s
@@ -103,3 +106,6 @@ volumes:
media_data: media_data:
external: true external: true
name: ${MEDIA_VOLUME_NAME:-customer_ticket_media} name: ${MEDIA_VOLUME_NAME:-customer_ticket_media}
seed_data:
external: true
name: ${SEED_VOLUME_NAME:-customer_ticket_seed}
+17 -2
View File
@@ -38,8 +38,23 @@ if [ "${RUN_STARTUP_TASKS:-0}" = "1" ]; then
echo "==> Applying database migrations..." echo "==> Applying database migrations..."
python manage.py migrate --noinput python manage.py migrate --noinput
echo "==> Bootstrapping required application data..." seed_dir="${PRODUCTION_SEED_DIR:-/app/production-seed}"
python manage.py bootstrap seed_available=0
if [ -f "$seed_dir/manifest.json" ]; then
echo "==> Importing production seed (idempotent)..."
python manage.py import_production_seed "$seed_dir"
seed_available=1
elif [ "${REQUIRE_PRODUCTION_SEED:-1}" = "1" ]; then
echo "ERROR: Required production seed is missing from $seed_dir" >&2
exit 1
fi
if [ "$seed_available" = "0" ]; then
echo "==> Bootstrapping required application data..."
python manage.py bootstrap
else
echo "==> Production seed is authoritative; skipping bootstrap mutations."
fi
echo "==> Collecting static files..." echo "==> Collecting static files..."
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
+11 -13
View File
@@ -10,14 +10,15 @@ PostgreSQL جزو این Compose نیست و از دیتابیس مدیریت‌
## 1. Volumeهای Coolify ## 1. Volumeهای Coolify
پیش از اولین deploy دو Docker volume با همین نام‌ها ایجاد کنید: پیش از اولین deploy سه Docker volume با همین نام‌ها ایجاد کنید:
```text ```text
customer_ticket_static customer_ticket_static
customer_ticket_media customer_ticket_media
customer_ticket_seed
``` ```
در صورت انتخاب نام دیگر، متغیرهای `STATIC_VOLUME_NAME` و `MEDIA_VOLUME_NAME` را مطابق آن تنظیم کنید. media حاوی آپلودهای ماندگار است. static در هر startup دوباره با `collectstatic` تکمیل می‌شود اما برای اشتراک بین web و Nginx volume لازم دارد. در صورت انتخاب نام دیگر، متغیرهای `STATIC_VOLUME_NAME`، `MEDIA_VOLUME_NAME` و `SEED_VOLUME_NAME` را مطابق آن تنظیم کنید. media حاوی آپلودهای ماندگار است. static در هر startup دوباره با `collectstatic` تکمیل می‌شود اما برای اشتراک بین web و Nginx volume لازم دارد. سه فایل تولیدشده در `production-seed/` باید پیش از اولین startup داخل ریشه volume سوم قرار گیرند.
## 2. دامنه‌ها و Traefik خود Coolify ## 2. دامنه‌ها و Traefik خود Coolify
@@ -47,7 +48,9 @@ DATABASE_WAIT_TIMEOUT=60
STATIC_VOLUME_NAME=customer_ticket_static STATIC_VOLUME_NAME=customer_ticket_static
MEDIA_VOLUME_NAME=customer_ticket_media MEDIA_VOLUME_NAME=customer_ticket_media
SEED_VOLUME_NAME=customer_ticket_seed
MEDIA_SIGNING_SECRET=<separate-random-secret-at-least-32-characters> MEDIA_SIGNING_SECRET=<separate-random-secret-at-least-32-characters>
REQUIRE_PRODUCTION_SEED=1
``` ```
نام دیتابیس انتهای URL باید نام واقعی دیتابیس باشد. اگر دیتابیس ساخته‌شده `customerTicket` است، URL باید به `/customerTicket` ختم شود؛ URLی که به `/postgres` ختم می‌شود به دیتابیس پیش‌فرض `postgres` متصل خواهد شد. اگر password شامل `@`، `:`، `/`، `#` یا `%` است آن را URL-encode کنید. نام دیتابیس انتهای URL باید نام واقعی دیتابیس باشد. اگر دیتابیس ساخته‌شده `customerTicket` است، URL باید به `/customerTicket` ختم شود؛ URLی که به `/postgres` ختم می‌شود به دیتابیس پیش‌فرض `postgres` متصل خواهد شد. اگر password شامل `@`، `:`، `/`، `#` یا `%` است آن را URL-encode کنید.
@@ -115,17 +118,12 @@ SECURE_HSTS_PRELOAD=false
## 7. ترتیب اولین deploy ## 7. ترتیب اولین deploy
1. volumeها و database خالی را ایجاد کنید. 1. volumeها و database خالی را ایجاد کنید.
2. Environment Variables را در Coolify وارد کنید. 2. طبق `docs/PRODUCTION_SEED.md` سه فایل seed را داخل volume `customer_ticket_seed` قرار دهید.
3. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفته‌شده را به `nginx:80` متصل کنید. 3. Environment Variables را در Coolify وارد کنید.
4. log سرویس `web` را برای موفقیت migrate، bootstrap، collectstatic و Gunicorn بررسی کنید. 4. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفته‌شده را به `nginx:80` متصل کنید.
5. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند. 5. log سرویس `web` را برای موفقیت migrate، import production seed، collectstatic و Gunicorn بررسی کنید. وقتی seed موجود است، bootstrap عمداً اجرا نمی‌شود تا snapshot تغییر نکند.
6. یک‌بار superuser بسازید: 6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
7. با superuser منتقل‌شده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
```bash
docker compose exec web python manage.py createsuperuser
```
7. در پنل مدیریت، SMS templateها و `NotificationPolicy`ها را بررسی کنید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
## 8. کنترل سلامت و عیب‌یابی ## 8. کنترل سلامت و عیب‌یابی
+79
View File
@@ -0,0 +1,79 @@
# انتقال خودکار SQLite به PostgreSQL Production
این فرایند تمام داده‌های پایدار فعلی را منتقل می‌کند:
- کاربران، hash رمزهای قابل استفاده، superuser، گروه‌ها و permissionها
- واحدهای سازمانی، عضویت‌ها، نقش‌ها و پروفایل پرسنل
- مشتریان، گروه مخاطبان، دسته‌بندی‌ها و SLA
- branding، عنوان صفحات، SMS templateها، NotificationPolicy و سیاست فایل‌ها
- تیکت‌ها، ارسال‌های گروهی، رویدادها، پیام‌ها، رسیدهای خواندن و attachmentها
- تمام فایل‌های موجود در `MEDIA_ROOT`، شامل branding، avatar و فایل تیکت
داده‌های موقتی و خطرناک زیر عمداً منتقل نمی‌شوند: sessionها، OTPهای قبلی، Magic Linkهای قبلی، admin log و `NotificationJob`. انتقال outbox قبلی می‌توانست بعد از بالا آمدن worker باعث ارسال مجدد SMS یا Bale شود.
## خروجی ساخته‌شده
دستور زیر از SQLite فعلی snapshot می‌گیرد:
```powershell
.\.venv\Scripts\python.exe manage.py export_production_seed --output production-seed --force
```
برای وضعیت فعلی پروژه خروجی قبلاً تولید شده و شامل ۳۹۵ رکورد پایدار و ۵۷ فایل media است:
```text
production-seed/
data.json.gz
media.tar.gz
manifest.json
```
این پوشه شامل اطلاعات شخصی و password hash است و عمداً توسط Git و Docker ignore می‌شود. آن را commit، ایمیل یا در فضای عمومی بارگذاری نکنید.
## انتقال امن به volume سرور
در Coolify volume خارجی `customer_ticket_seed` را ایجاد کنید. سه فایل بالا باید مستقیماً در ریشه آن قرار بگیرند، به شکلی که container مسیر زیر را ببیند:
```text
/app/production-seed/manifest.json
/app/production-seed/data.json.gz
/app/production-seed/media.tar.gz
```
فایل‌ها را با قابلیت Persistent Storage/File Mount خود Coolify یا یک انتقال امن مانند SCP به سرور ببرید. اگر روی سرور در `/tmp/customer-ticket-seed` قرار گرفته‌اند، مدیر سرور می‌تواند volume را این‌گونه پر کند:
```bash
docker run --rm \
-v customer_ticket_seed:/target \
-v /tmp/customer-ticket-seed:/source:ro \
alpine:3.20 sh -c 'cp /source/data.json.gz /source/media.tar.gz /source/manifest.json /target/'
```
پس از کپی، نسخه موجود در `/tmp` را امن حذف کنید. volume seed در Compose فقط read-only داخل web mount می‌شود.
## اجرای خودکار
در اولین startup سرویس web ترتیب زیر اجرا می‌شود:
1. انتظار برای PostgreSQL
2. اجرای migrationها
3. اعتبارسنجی SHA-256 فایل‌های seed
4. اطمینان از خالی بودن PostgreSQL و media volume
5. حذف defaultهای data migration و import کامل snapshot
6. انتقال media به volume اصلی
7. ثبت `ProductionSeedState`
8. رد کردن `bootstrap` برای حفظ دقیق snapshot به‌عنوان منبع authoritative
9. اجرای `collectstatic` و Gunicorn
در deployهای بعدی checksum قبلاً ثبت‌شده تشخیص داده می‌شود و import دوباره انجام نمی‌شود. اگر bundle نباشد، با `REQUIRE_PRODUCTION_SEED=1` سرویس عمداً متوقف می‌شود تا production ناقص بالا نیاید.
## محدودیت‌های ایمنی
- import فقط روی PostgreSQL اجرا می‌شود.
- دیتابیس دارای User یا seed قبلی با checksum متفاوت پذیرفته نمی‌شود.
- media volume غیرخالی overwrite نمی‌شود.
- مسیرهای مشکوک، symlink و path traversal در archive رد می‌شوند.
- checksum هر دو فایل پیش از هر تغییر بررسی می‌شود.
- شکست import باعث rollback دیتابیس و حذف فایل‌های کپی‌شده همان اجرا می‌شود.
اگر واقعاً می‌خواهید production بدون snapshot بالا بیاید، `REQUIRE_PRODUCTION_SEED=0` بگذارید؛ در این حالت فقط bootstrap استاندارد اجرا می‌شود و کاربران دارای password فعلی SQLite منتقل نخواهند شد.
+72
View File
@@ -0,0 +1,72 @@
import gzip
import json
import pytest
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.db import connection
from apps.accounts.models import User
from apps.core.models import ProductionSeedState
from apps.notifications.models import NotificationJob
from apps.staff.models import OrganizationalUnit, StaffProfile
@pytest.mark.django_db(transaction=True)
def test_seed_bundle_round_trip_is_complete_idempotent_and_excludes_outbox(tmp_path, settings):
media_root = tmp_path / "source-media"
settings.MEDIA_ROOT = media_root
(media_root / "branding" / "logo").mkdir(parents=True)
(media_root / "branding" / "logo" / "current.png").write_bytes(b"seed-media")
unit = OrganizationalUnit.objects.create(name="واحد Seed", slug="seed-unit")
user = User.objects.create_user(
username="seed-staff",
password="not-a-production-password",
account_type=User.AccountType.STAFF,
)
StaffProfile.objects.create(user=user, primary_unit=unit, display_name="کارشناس Seed")
permission = Permission.objects.order_by("pk").first()
permission_key = (permission.content_type.app_label, permission.codename)
unit.permissions.add(permission)
membership = user.unit_memberships.get(unit=unit)
membership.permissions.add(permission)
user.user_permissions.add(permission)
user.groups.get().permissions.add(permission)
NotificationJob.objects.create(
channel="BALE",
event_type="SHOULD_NOT_IMPORT",
dedupe_key="excluded-outbox-job",
next_attempt_at="2026-09-10T00:00:00Z",
)
bundle_dir = tmp_path / "bundle"
call_command("export_production_seed", output=str(bundle_dir))
with gzip.open(bundle_dir / "data.json.gz", "rt", encoding="utf-8") as stream:
serialized = json.load(stream)
assert any(row["model"] == "accounts.user" for row in serialized)
assert not any(row["model"] == "notifications.notificationjob" for row in serialized)
call_command("flush", interactive=False, verbosity=0)
target_media = tmp_path / "target-media"
settings.MEDIA_ROOT = target_media
original_vendor = connection.vendor
connection.vendor = "postgresql"
try:
call_command("import_production_seed", str(bundle_dir))
call_command("import_production_seed", str(bundle_dir))
finally:
connection.vendor = original_vendor
restored = User.objects.get(username="seed-staff")
assert restored.check_password("not-a-production-password")
assert restored.staff_profile.primary_unit.slug == "seed-unit"
restored_membership = restored.unit_memberships.get(unit__slug="seed-unit")
permission_filter = {"content_type__app_label": permission_key[0], "codename": permission_key[1]}
assert restored.user_permissions.filter(**permission_filter).exists()
assert restored.groups.get().permissions.filter(**permission_filter).exists()
assert restored_membership.permissions.filter(**permission_filter).exists()
assert restored_membership.unit.permissions.filter(**permission_filter).exists()
assert ProductionSeedState.objects.count() == 1
assert NotificationJob.objects.count() == 0
assert (target_media / "branding" / "logo" / "current.png").read_bytes() == b"seed-media"