Compare commits

..

20 Commits

Author SHA1 Message Date
43e02adf37 216 sshd: LoginGraceTime back to 2m (30s silently cut off people typing a long password)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:59:59 +03:30
e25ffb5575 CLAUDE.md: 216 password is the AdminUser-Password line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:48:23 +03:30
eb03b467d4 216: shared admin account mathapp-admin, temp removed, hostname mathapp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:50:17 +03:30
f2c57c1ad1 docs: step-by-step cut-over to 216 with rollback, what waits on others, after-cut-over list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:18:40 +03:30
f6d779255d docs: what was tested on 216 (end-to-end user flow, restore from the 247 encrypted copy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:08:38 +03:30
4cd859b5fa 216 backups encrypted on 216: pgBackRest (AES-256) + age-encrypted payment CSVs, pushed write-only to 247 with rrsync; health watches pg_stat_archiver and OFFSITE_SYNC_STATUS_FILE; 216 scripts/configs in ops/server216; plaintext pg216 pipeline removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:43:00 +03:30
dd98f16d12 Live backup of 216 on 247 (zareian-pg216-* units, Postgres 16 tools in our dir, SCRAM hash only on 216), backup scripts take PGB_ROOT/PGB_BIN/PGB_PORT with 249 defaults, anti-bot layers documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:22:56 +03:30
bd329f83ae Dedicated server 78.38.35.216: state and cut-over order in docs, CLAUDE.md entry, password snippet for two Password lines, archive-app-log.sh finds its project dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:06:04 +03:30
670513b462 tools/figures: server_fixups.json (corrections of already-published questions and lessons), doc line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:36:00 +03:30
133233144e Seed: grade 12 questions (every lesson 4+ per lesson, answers computed in code), minus-sign and doubled-x fixes in grade 10 and 11
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:35:34 +03:30
a5a93ef6ba Seed: grade 11 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:27:10 +03:30
5f963e2898 Seed: grade 10 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:16:51 +03:30
7bd7c637b4 Seed: grade 9 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:00:27 +03:30
ac96f93abc Seed: grade 8 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:48:33 +03:30
9903e0ec91 Seed: grade 5 rebuilt (every lesson 4+ questions computed in code), wording fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:30:33 +03:30
759bb0d575 Seed: grade 4 rebuilt (every lesson 4+ questions computed in code), slash decimals fixed in chapter 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:06:21 +03:30
f8a93a37c9 Seed: grade 7 rebuilt (every lesson 5+ questions computed in code), lost-exponent questions fixed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:47:44 +03:30
9eae99340d Seed: grade 6 rebuilt (every lesson 5+ questions, answers computed in code), supplementary/complementary text fixed in grades 4/6/7, '/' decimals to '٫', wrong keys fixed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:31:28 +03:30
f57deb9c1d Seed: grade 3 rebuilt (every lesson 5+ questions, wrong figures/lesson text fixed), Persian digits and plain fractions across all grades, apply_fixups gains lesson/question_set, ledger save chunked for SQLite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:27:22 +03:30
5fbbd371fe Seed: questions for the 499 lessons that had none; every lesson now has questions (5,406/5,406)
998 four-choice questions (2 per lesson), written from each lesson's own text,
worked example and the book's extracted problem. Mostly grades 3, 6, 7 (books
imported before us), plus 4, 5 and a few in 8-12. Hands-on lessons (drawing,
measuring, folding) got text questions that test the same idea.
Checked: structure (4 distinct choices, no LaTeX), a blind re-solve of all 998
against the key (no mismatch), and content.tests_seed_content seeding the full
file. Not checked: an independent second solver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:15:13 +03:30
38 changed files with 359760 additions and 13898 deletions

View File

@@ -39,6 +39,9 @@ DB_PORT=5432
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247. # اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247.
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی. # خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی.
OFFSITE_WAL_SLOT= OFFSITE_WAL_SLOT=
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن می‌نویسد.
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپ‌تاپ خالی.
OFFSITE_SYNC_STATUS_FILE=
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند # ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند

View File

@@ -63,10 +63,18 @@ other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gi
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
request to the server admin, not something to fix. request to the server admin, not something to fix.
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` **`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` (249's
and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its live copy), `~/zareian-pgbackrest-216/` (216's backups, encrypted on 216; 247 never holds the key), our lines in
`~/.ssh/authorized_keys` and `~/.pgpass`, `~/.ssh/zareian_*`, and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
own `postgresql@14-main`, which we never use. own `postgresql@14-main`, which we never use.
**`78.38.35.216` (hostname `mathapp`, new dedicated VM, not yet serving users)**, account `zareian` (sudo; the
senior and the company owner share `mathapp-admin`, also sudo; no other logins): the whole machine is ours. The app runs as the non-sudo user `mathapp` under `/home/mathapp/`; same unit names as on 249.
Both accounts use the password on the `AdminUser-Password` line of the password file (the `Password` line under
`NewServer` is older). State and the cut-over order:
`docs/operations.md` «سرورِ اختصاصیِ جدید». Until cut-over, 249 stays the live server and every rule below applies
there unchanged; on 216, rules 1, 5, 6, 8 and 10 still apply.
### Rules (not negotiable) ### Rules (not negotiable)
1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`, 1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`,
@@ -114,7 +122,8 @@ session. Take only the password line, everything after the first `: ` (stripping
once sent the whole line and SSH refused it): once sent the whole line and SSH refused it):
```bash ```bash
sed -n '/^Password/{s/^[^:]*: *//;p;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247 (first Password line)
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>' sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw" sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time
@@ -300,7 +309,7 @@ has been done before.
### Content ### Content
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects, - `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
89 units, 1,145 islands, 5,406 lessons, 18,522 questions. `deploy.sh` runs `seed_content` on every release. 89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 39,156 questions. `deploy.sh` runs `seed_content` on every release.
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers - `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`, what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path** `steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**

View File

@@ -13,7 +13,7 @@
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` | | **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud | | **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) | | **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۱۸٬۵۲۲ سوال | | **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۳۹٬۱۵۶ سوال |
| **تست** | ۹۷۳ تستِ خودکار، همه سبز | | **تست** | ۹۷۳ تستِ خودکار، همه سبز |
| **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) | | **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) |

View File

@@ -14,6 +14,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
""" """
import logging import logging
import os import os
import time
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
@@ -160,6 +161,8 @@ def _offsite_backup() -> dict:
""" """
if connection.vendor != 'postgresql': if connection.vendor != 'postgresql':
return {'state': 'not_postgres'} return {'state': 'not_postgres'}
if settings.OFFSITE_SYNC_STATUS_FILE:
return _pushed_backup()
slot = settings.OFFSITE_WAL_SLOT slot = settings.OFFSITE_WAL_SLOT
if not slot: if not slot:
return {'state': 'not_configured'} return {'state': 'not_configured'}
@@ -193,6 +196,45 @@ def _slot_state(row) -> dict:
return {'state': state, 'lagBytes': lag} return {'state': state, 'lagBytes': lag}
def _pushed_backup() -> dict:
"""
بکاپِ رمزشده‌ی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو می‌کند (pg_stat_archiver) و
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ می‌فرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE می‌نویسد.
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بی‌تغییر WALِ تازه‌ای ندارد که آرشیو شود.
"""
try:
with connection.cursor() as cursor:
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
last_archived, last_failed = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
try:
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
synced_at = int(f.read().strip())
except FileNotFoundError:
synced_at = None
except (OSError, ValueError) as exc:
return {'state': 'error', 'error': type(exc).__name__}
return _pushed_state(last_archived, last_failed, synced_at, time.time())
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
age = None if synced_at is None else int(now - synced_at)
if last_failed is not None and (last_archived is None or last_failed > last_archived):
state = 'archive_failing'
elif age is None:
state = 'never_synced'
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
state = 'sync_stale'
else:
state = 'ok'
return {
'state': state,
'syncAgeSeconds': age,
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
}
# اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و # اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests # migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
# عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool). # عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool).
@@ -267,6 +309,9 @@ _OFFSITE_MESSAGES = {
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.', 'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.', 'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.', 'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.',
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست می‌خورد و تغییراتِ تازه بکاپ نمی‌شوند: sudo -u postgres pgbackrest --stanza=zareian check',
'never_synced': 'بکاپِ رمزشده هنوز هیچ‌وقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
} }

View File

@@ -1078,6 +1078,12 @@ LOG_DIR = os.environ.get('LOG_DIR', '')
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '') OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است. # بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20 OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشده‌ی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ می‌فرستد و زمانِ
# آخرین ارسالِ موفق (epoch) را در این فایل می‌نویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف می‌شود.
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
# ارسال هر دقیقه است؛ ۱۰ دقیقه بی‌ارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛ # چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛
# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و # 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و

View File

@@ -3,6 +3,8 @@
پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ
واقعی دیده شد (docs/operations.md «بکاپ»). واقعی دیده شد (docs/operations.md «بکاپ»).
""" """
from datetime import datetime, timedelta, timezone
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from config import health from config import health
@@ -27,13 +29,45 @@ class SlotStateTests(SimpleTestCase):
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok') self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
class PushedStateTests(SimpleTestCase):
"""بکاپِ رمزشده‌ی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه این‌جا."""
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
def test_recent_sync_is_ok(self):
r = health._pushed_state(self.T0, None, 1000, now=1060)
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
def test_old_sync_is_stale(self):
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
def test_no_status_file_is_never_synced(self):
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
def test_failure_after_last_success_wins(self):
later = self.T0 + timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
def test_old_failure_then_success_is_ok(self):
earlier = self.T0 - timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
def test_idle_database_with_no_archive_yet_is_ok(self):
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['state'], 'ok')
class OffsiteWarningTests(SimpleTestCase): class OffsiteWarningTests(SimpleTestCase):
def test_ok_and_dev_are_silent(self): def test_ok_and_dev_are_silent(self):
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), []) self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), []) self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
def test_every_bad_state_warns(self): def test_every_bad_state_warns(self):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'): for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
'archive_failing', 'never_synced', 'sync_stale'):
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state) self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
def test_sqlite_reports_not_postgres(self): def test_sqlite_reports_not_postgres(self):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,11 @@ class _Ledger:
self.write[key] = (kind, value) self.write[key] = (kind, value)
def save(self) -> None: def save(self) -> None:
existing = set(SeedRecord.objects.filter(key__in=list(self.write)).values_list('key', flat=True)) # دسته‌دسته: SQLite بیش از ۳۲٬۷۶۶ متغیر در یک پرس‌وجو نمی‌پذیرد و دفترِ یک seedِ تازه از آن بیشتر است.
keys = list(self.write)
existing = set()
for i in range(0, len(keys), 500):
existing.update(SeedRecord.objects.filter(key__in=keys[i:i + 500]).values_list('key', flat=True))
SeedRecord.objects.bulk_create([ SeedRecord.objects.bulk_create([
SeedRecord(key=k, kind=kind, value=value) SeedRecord(key=k, kind=kind, value=value)
for k, (kind, value) in self.write.items() if k not in existing for k, (kind, value) in self.write.items() if k not in existing

View File

@@ -0,0 +1,61 @@
"""tools/figures/apply_fixups.py --db: اصلاحِ محتوای منتشرشده روی دیتابیس، بدون از دست رفتنِ پاسخ‌های ثبت‌شده."""
import importlib.util
import os
from django.conf import settings
from django.test import TestCase
from content.models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer
_spec = importlib.util.spec_from_file_location(
'apply_fixups', os.path.join(settings.BASE_DIR, 'tools', 'figures', 'apply_fixups.py'))
apply_fixups = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(apply_fixups)
PATH = ['پایه سوم', 'فصل ۱', 'جزیره', 'درس']
class ApplyFixupsDbTests(TestCase):
def setUp(self):
g = Grade.objects.create(title='پایه سوم', icon='', color='')
u = Unit.objects.create(grade=g, title='فصل ۱', order=1)
n = Node.objects.create(unit=u, title='جزیره', order=1)
self.lesson = Lesson.objects.create(node=n, title='درس', intro_text='متن', example='مثالِ غلط',
steps=['گام'], figures=[{'where': 'example', 'diagram': {}}])
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='(۱)/(۲) کدام است؟')
self.c = [Choice.objects.create(question=self.q, value=v, is_correct=(i == 0))
for i, v in enumerate(['(۱)/(۲)', '۲', '۳', '۴'])]
self.answer = UserAnswer.objects.create(user_id=7, question=self.q, choice=self.c[0], correct=True)
self.dup = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='تکراری')
def test_question_set_keeps_rows_and_answers(self):
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {
'prompt': '۱/۲ کدام است؟', 'choices': ['۱/۲', '۲', '۳', '۴'], 'correct': 0}]]})
self.assertEqual(problems, [])
self.q.refresh_from_db()
self.assertEqual(self.q.prompt, '۱/۲ کدام است؟')
self.assertEqual([c.value for c in self.q.choices.order_by('id')], ['۱/۲', '۲', '۳', '۴'])
self.assertEqual([c.pk for c in self.q.choices.order_by('id')], [c.pk for c in self.c])
self.assertTrue(UserAnswer.objects.filter(pk=self.answer.pk, choice=self.c[0]).exists())
def test_choice_count_mismatch_is_reported_not_applied(self):
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {'choices': ['۱', '۲']}]]})
self.assertEqual(len(problems), 1)
self.assertEqual(self.c[0].__class__.objects.get(pk=self.c[0].pk).value, '(۱)/(۲)')
def test_lesson_fields_and_delete(self):
problems = apply_fixups.apply_db({
'lesson': [[PATH, {'example': 'مثالِ درست', 'figures': []}]],
'delete': [[PATH, 'تکراری']]})
self.assertEqual(problems, [])
self.lesson.refresh_from_db()
self.assertEqual((self.lesson.example, self.lesson.figures, self.lesson.steps), ('مثالِ درست', [], ['گام']))
self.assertFalse(Question.objects.filter(pk=self.dup.pk).exists())
def test_missing_targets_are_reported(self):
problems = apply_fixups.apply_db({'delete': [[PATH, 'نیست']], 'lesson': [[['x', 'y', 'z', 'w'], {'steps': []}]]})
self.assertEqual(len(problems), 2)
def test_unknown_field_stops(self):
with self.assertRaises(SystemExit):
apply_fixups.apply_db({'lesson': [[PATH, {'title': 'نه'}]]})

View File

@@ -11,7 +11,7 @@
| فایل | محتوا | | فایل | محتوا |
|---|---| |---|---|
| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) | | `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) |
| `questions.json` | ۱۸٬۵۲۲ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) | | `questions.json` | ۳۹٬۱۵۶ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) |
هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها
(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ (`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ
@@ -180,3 +180,5 @@ python tools/figures/batch.py merge batch.json # افزود
اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالی‌کردنِ عنوان، هم روی seed هم با `--db` روی سرور، اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالی‌کردنِ عنوان، هم روی seed هم با `--db` روی سرور،
پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمی‌کند. پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمی‌کند.
فایل `tools/figures/server_fixups.json` اصلاح‌های سوال‌ها و درس‌هایی است که پیش از بازسازیِ پایه‌های ۳ تا ۱۲ منتشر شده بودند
(۹۳ حذف، ۵۸۵ اصلاحِ سوال، ۲۷۳۸ اصلاحِ درس)؛ پس از هر استقرارِ seed با `apply_fixups.py --db` روی سرور اجرا می‌شود.

View File

@@ -382,6 +382,104 @@ the servers»):
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید - سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
(WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه. (WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه.
## سرورِ اختصاصیِ جدید (`78.38.35.216`): آماده، هنوز سوئیچ نشده
اپ به یک ماشینِ مجازیِ اختصاصی منتقل می‌شود. کاربران هنوز از ۲۴۹ سرو می‌شوند؛ ArvanCloud هنوز به ۲۴۹ اشاره می‌کند.
| | |
|---|---|
| ماشین | VMware، Ubuntu 24.04، ۸ هسته، ۱۲ گیگ رم، دیسکِ ۹۷ گیگ؛ hostname `mathapp`، IPِ داخلی `192.168.200.163` (۲۴۹ = `.164`)، IPِ خروجی `78.38.35.228` |
| ورود | دو حساب با sudo: `zareian` (صاحبِ محصول) و `mathapp-admin` (مشترکِ سینیور و صاحبِ شرکت؛ رمزش در فایلِ رمز زیرِ `AdminUser`). `temp` حذف شد (۲۰۲۶-۱۰-۱۷؛ authorized_keysش در `/home/zareian/backups/`). ورودِ root بسته، `AllowUsers zareian mathapp-admin`، forwarding بسته (`10-tadrisino.conf`) |
| فایل‌ها | نسخه‌ی اصلیِ همه‌ی اسکریپت‌ها و تنظیم‌های این سرور در `ops/server216/` (بی‌کلید). تغییر: فایلِ ریپو، بعد کپی روی سرور |
| ضدِ ربات | ۱) `tadrisino-blocklist.timer`: هر روز FireHOL level1 (Spamhaus، DShield، …) در ipsetِ `tadrisino-block`، همه‌ی پورت‌ها؛ شبکه‌ی داخلی، `78.38.35.0/24` و رنج‌های آروان هرگز در آن نیستند. ۲) `ufw limit 22`: بیش از ۶ اتصال در ۳۰ ثانیه رد. ۳) fail2ban پله‌ای: ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه، هر تکرار دو برابر تا یک هفته؛ `recidive`: ۵ بار در روز → یک هفته همه‌ی پورت‌ها؛ `192.168.200.0/24` هرگز. آزاد کردن: `sudo fail2ban-client set sshd unbanip <IP>` |
| بکاپ | **رمزنگاری روی همین سرور، ۲۴۷ فقط فایلِ رمزشده می‌بیند** (۲۴۷ مشترک است و هر کس `administrator` را دارد root است). pgBackRest با `repo1-cipher-type=aes-256-cbc`: آرشیوِ پیوسته‌ی WAL (`archive_timeout=60`، یعنی حداکثر یک دقیقه تغییرِ آرشیونشده)، `tadrisino-pgbackrest-full` یکشنبه‌ها، `-diff` هر روز، دو نسخه‌ی کامل نگه داشته می‌شود. CSVهای پرداخت هر ۶ ساعت با age رمز می‌شوند (`tadrisino-export-payment-events`). `tadrisino-offsite-sync` هر دقیقه `/var/lib/pgbackrest` و `payment-log` را به `~/zareian-pgbackrest-216/` روی ۲۴۷ می‌فرستد؛ کلیدِ postgres در `authorized_keys`ِ ۲۴۷ با `from=192.168.200.163,restrict,command="rrsync -wo …"` فقط می‌تواند در همان پوشه بنویسد (نه بخواند، نه شِل). `/api/health` → `offsiteBackup` از `pg_stat_archiver` و `OFFSITE_SYNC_STATUS_FILE` |
| کلیدهای بازیابی | `/etc/tadrisino-backup/RECOVERY-KEYS` (root، 600): کلیدِ pgBackRest و کلیدِ age. **بی‌آن‌ها بکاپِ ۲۴۷ خواندنی نیست**؛ یک نسخه بیرون از سرورها نزدِ صاحبِ محصول لازم است: `ssh -t zareian@78.38.35.216 sudo cat /etc/tadrisino-backup/RECOVERY-KEYS` در ترمینالِ خودش (نه در چت) |
| آزمونِ بازیابی | `sudo -u postgres tadrisino-restore-check` (ماهی یک بار): بازیابی در پوشه‌ی موقت و مقایسه‌ی شمارش‌ها با دیتابیسِ زنده. بازیابیِ واقعی از ۲۴۷ (اگر ۲۱۶ از دست رفت): پوشه‌ی `repo/` را از ۲۴۷ کپی، `pgbackrest.conf` با کلید از RECOVERY-KEYS، `pgbackrest --stanza=zareian restore` |
| اپ | کاربرِ بی‌sudoِ `mathapp`؛ `/home/mathapp/mathapp-v2` و `/home/mathapp/mathapp_frontend_new` |
| سرویس‌ها | `zareian-mathapp` (= `gunicorn-mathapp`، با `RuntimeDirectory`)، `zareian-redis` (6390)، `postgresql@16-zareian` (5440)، `nginx`، `zareian-applog-archive.timer` |
| رمزها | رمزِ Postgres و Redis روی این سرور تازه ساخته شده (`.pg_password`، `.redis_password` کنارِ `.env`)، نه کپیِ ۲۴۹ |
| فایروال | ufw فقط 22/80/443. فایروالِ شبکه‌ی شرکت 443ِ ورودی را هنوز می‌بندد (80 باز است) |
| بسته‌ها | apt از `mirror.arvancloud.ir`، pip از `mirror-pypi.runflare.com` (`~mathapp/.config/pip/pip.conf`)، npm از `mirror-npm.runflare.com`؛ `files.pythonhosted.org` و `registry.npmjs.org` از این سرور بسته‌اند |
سنجیده: `/api/health` با همه‌ی جزئیات ok، آزمونِ بازیابی از بکاپِ رمزشده با همان شمارش‌ها، فایل‌های ۲۴۷ رمزشده (`Salted__`،
`age-encryption.org/v1`)، کلیدِ ۲۱۶ روی ۲۴۷ نه می‌خواند نه شِل می‌گیرد، کپیِ ۲۴۷ با `rsync --checksum` عینِ مخزنِ محلی، Qdrant از این سرور وصل، فرانت ۲۰۰، ریبوت و
بالا آمدنِ خودکارِ همه‌ی سرویس‌ها، درخواستِ مستقیم به IP ۴۰۳ (nginx IPِ واقعی را می‌بیند)، بازیابیِ کامل از نسخه‌ی رمزشده‌ی
خودِ ۲۴۷ (دیتابیس و CSV؛ عقب‌ماندگیِ طراحی‌شده حداکثر ~۲ دقیقه). سفرِ کاملِ کاربر از nginx+TLS روی خودِ سرور (۲۹ مورد): فرانت و
هدرهای امنیتی، پیامکِ واقعیِ کاوه‌نگار و ورود، نقشه، آزمونِ جزیره و پایانش، پیِ زنده، سفارش و redirect به StartPayِ زرین‌پال (بی‌پرداخت)،
خروج و باطل شدنِ توکن. کاوه‌نگار و GapGPT از IPِ خروجیِ این سرور پذیرفته‌اند. نسنجیده: هیچ درخواستی از مسیرِ ArvanCloud
(۴۴۳ بسته است)، پرداختِ کامل، خریدِ بازار.
### چه چیزی منتظرِ دیگران است
درخواست از سینیور فرستاده شده (۲۰۲۶-۱۰-۱۷): ۱) 443ِ ورودی روی فایروالِ شرکت؛ ۲) (انجام شد: `temp` حذف و `mathapp-admin` ساخته شد)؛ ۳) عوض کردنِ originِ
`math.tadrisino2.ir` و `mathapi.tadrisino2.ir` در پنلِ ArvanCloud به `78.38.35.216` (ما به پنل دسترسی نداریم؛ هم‌زمان با قدمِ ۴ پایین)؛
۴) private کردنِ مخزن در Gitea؛ ۵) ۱۲ هسته و رمِ بیشتر (اختیاری؛ سرور ریبوت را تحمل می‌کند، تست شده). زرین‌پال و بازار به
دامنه بسته‌اند نه IP، پس در پنلِ آن‌ها چیزی عوض نمی‌شود.
### سوئیچ، قدم‌به‌قدم (وقتی سینیور گفت آماده است)
1. **پیش‌شرط:** از لپ‌تاپ `curl -sk -o /dev/null -w '%{http_code}' https://78.38.35.216/` باید ۴۰۳ بدهد (نه ۰۰۰: یعنی 443 باز است و
nginx فقط آروان را راه می‌دهد). کامیت‌ها push شده باشند و `deploy.sh` روی هر دو سرور اجرا شده باشد (کدِ دو طرف یکی).
2. **۲۴۹ را بخوابان:** `sudo systemctl stop zareian-mathapp` (از این لحظه کاربران خطا می‌بینند تا قدمِ ۵؛ کلِ کار ~۱۵ دقیقه).
3. **دیتابیسِ نهایی:** `sudo -u postgres pg_dump -p 5440 -Fc zareian_mathapp` روی ۲۴۹، با pipe از لپ‌تاپ به
`/home/zareian/stage/` روی ۲۱۶ (مسیرِ داخلیِ ۲۴۹→۲۱۶ کلید ندارد).
4. **روی ۲۱۶:** `systemctl stop zareian-mathapp`؛ `dropdb -p 5440 zareian_mathapp && createdb -p 5440 -O zareian_app zareian_mathapp` (با
`sudo -u postgres`)؛ `pg_restore -p 5440 -d zareian_mathapp --no-owner --role=zareian_app --exit-on-error`؛ شمارشِ `accounts_user`،
`content_question`، `payment_order`، `payment_paymentevent`، `content_useranswer` با همان پرسش روی ۲۴۹ یکی باشد؛ `systemctl start
zareian-mathapp`؛ health با `X-Internal-Key` بی‌هشدار. داده‌های تستِ ۲۰۲۶-۱۰-۱۷ روی ۲۱۶ با همین قدم پاک می‌شوند.
5. **سینیور origin را در آروان عوض می‌کند.** بعد از بیرون: `https://mathapi.tadrisino2.ir/api/health` ok؛ در
`/var/log/nginx/access.log`ِ ۲۱۶ IPهای آروان دیده شوند؛ ورود در Chrome (روشِ OTPِ ادمین، بخشِ «ورود بی‌پیامک») و یک آزمون؛ کاربرِ
اندروید (WebView و `pay.html`) هم یک بار.
6. **بکاپِ تازه:** `sudo systemctl start tadrisino-pgbackrest-full` و بعد `tadrisino-offsite-sync`؛ `sudo -u postgres tadrisino-restore-check`.
7. **۲۴۹ را کنار بگذار (پاک نکن):** `sudo systemctl disable zareian-mathapp` روی ۲۴۹؛ اسلات را بردار تا WAL روی دیسکِ مشترک جمع نشود:
`sudo -u postgres psql -p 5440 -c "select pg_drop_replication_slot('zareian_247')"`؛ روی ۲۴۷ `systemctl disable --now zareian-pg-tunnel
zareian-pg-wal zareian-pg-basebackup.timer zareian-pg-export-events.timer zareian-applog-pull.timer`. دیتابیسِ ۲۴۹ یک هفته برای برگشت می‌ماند.
8. **برگشت (اگر قدمِ ۵ خراب شد):** origin در آروان دوباره ۲۴۹، و روی ۲۴۹ `systemctl start zareian-mathapp`. فقط تا وقتی درست است که ۲۱۶
نوشتنِ واقعی (پرداخت، ثبت‌نام) نگرفته؛ بعد از آن اول dumpِ ۲۱۶ به ۲۴۹.
9. **اسناد:** CLAUDE.md (سرورِ زنده = ۲۱۶؛ رویه‌ی دیپلو با `zareian@78.38.35.216` و `sudo -u mathapp ./deploy.sh`)، این بخش، و
`ops/nginx/` با مسیرهای `/home/mathapp` (۲۱۶ نسخه‌ی sed‌شده را دارد).
### بعد از سوئیچ
- یک هفته بعد، با تأییدِ صاحبِ محصول: پاک کردنِ بکاپ‌های رمزنگاری‌نشده‌ی ۲۴۹ روی ۲۴۷ (`~/zareian-pgbackup`، یونیت‌های `zareian-pg-*`)
و حذفِ `_slot_state`/`OFFSITE_WAL_SLOT` از `config/health.py` و `settings.py`.
- لاگِ اپ: آرشیوِ شبانه روی ۲۱۶ هست (`zareian-applog-archive.timer`) ولی هنوز به ۲۴۷ نمی‌رود؛ راهِ پیشنهادی: افزودنِ
`logs/archive` به `tadrisino-offsite-sync` (همان rrsync، پوشه‌ی `app-logs/`).
- ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول خواسته فعلاً بماند؛ روی لپ‌تاپ `ssh-keygen -t ed25519` و `ssh-copy-id`)، بعد
`PasswordAuthentication no` با `Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکه‌ی داخلی.
- توکنِ فقط‌خواندنیِ Gitea برای `deploy.sh` روی ۲۱۶ (بعد از private شدنِ مخزن، `git fetch` بی‌رمز دیگر کار نمی‌کند).
- `CORS_ALLOWED_ORIGINS` در `.env`ِ سرور هنوز `localhost:5174` دارد؛ حذفش.
- برنامه‌ی صاحبِ محصول: مرورِ کاملِ یک فهرستِ امنیتیِ استاندارد (OWASP Top 10 / API Top 10 برای اپ، CIS Benchmark برای سرور، همراهِ
`docs/security-before-production.md`)، بعد لودتست روی ۲۱۶ (`tools/loadtest/README.md`؛ با هسته‌ی بیشتر: ۸ worker و `DB_POOL_MAX_SIZE` 6).
### Qdrant
**وضعِ فعلی Qdrant:** بردارهای RAG روی `https://qdrant.tadrisino2.ir` هستند (پشت ArvanCloud؛ سرورِ اصلی از بیرون پیدا
نیست و در فهرستِ سرورهای ما نیست. `rag_catalog` آن را سرورِ تیمِ دیگر می‌داند). نسخهٔ 1.17.0، ۳۴ collection، حدود
۱۰۸۸۰ نقطه، همه با بُعد ۱۵۳۶ و Cosine.
**هدف:** همهٔ کتاب‌های ریاضی‌ای که در Qdrant هست (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ پایه‌های سوم تا دوازدهم) روی یک
Qdrant اختصاصی ببریم و اپ را به آن وصل کنیم، بدون ingest دوباره، چون ingest هزینهٔ GapGPT دارد.
**کدام collectionها (۱۵ تا، حدود ۵۶۸۰ نقطه):**
- ریاضی سوم، چهارم، پنجم، ششم، هفتم، هشتم، نهم، دهم.
- حسابان یازدهم، دوازدهم.
- هندسه دهم، یازدهم، دوازدهم.
- آمار و احتمال یازدهم.
- گسسته دوازدهم.
ریاضیِ سوم، ششم و هفتم را تیمِ دیگر ingest کرده، و صاحبِ محصول خواسته هر سه منتقل شوند. بقیهٔ collectionها (علوم، فارسی،
مطالعات اجتماعی، هدیهٔ آسمان، پیام‌های آسمان) در این برنامه نیستند.
**اصولِ انتقال:**
- از هر collection یک snapshot بگیرید، فایلِ آن را منتقل و روی Qdrant جدید بارگذاری کنید (بدون فراخوانیِ OpenAI).
- بعد از انتقال: تعدادِ نقاط، بُعد و Cosine یکی باشند؛ یک بردارِ ذخیره‌شده را به‌عنوانِ query بدهید و همان نقطه بالا بیاید.
- `/api/rag/ingest` هنوز روی Qdrant فعلی می‌نویسد. در بازهٔ کپی ingest متوقف شود، یا بعد از سوئیچ نقاطِ تازه دوباره همگام شوند.
- `QDRANT_URL` و `QDRANT_API_KEY` را فقط **بعد** از تأییدِ کپی عوض کنید؛ زودتر عوض شود، تیوتور جوابِ ناقص می‌دهد.
**باز:** محلِ سرورِ اصلیِ فعلیِ Qdrant (ادعای «روی سرورِ تیمِ دیگر» هنوز از روی سرور تأیید نشده). سرورِ جدید فعلاً به همان
`qdrant.tadrisino2.ir` وصل است.
## لاگ‌ها ## لاگ‌ها
هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود

View File

@@ -20,7 +20,7 @@
|---|---| |---|---|
| پایه‌های سوم تا دوازدهم | **۱۰ پایه** | | پایه‌های سوم تا دوازدهم | **۱۰ پایه** |
| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** | | کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** |
| سؤالِ چهارگزینه‌ای | **۱۸٬۵۲۲** | | سؤالِ چهارگزینه‌ای | **۲۲٬۶۰۲** |
| عملیاتِ API، مستند در Swagger | **۶۱** | | عملیاتِ API، مستند در Swagger | **۶۱** |
| تستِ خودکار، همه سبز | **۹۷۳** | | تستِ خودکار، همه سبز | **۹۷۳** |
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** | | اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
@@ -55,7 +55,7 @@
### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند) ### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند)
پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۱۸٬۵۲۲ ← گزینه Choice پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۲۲٬۶۰۲ ← گزینه Choice
**«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد. **«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد.
@@ -732,7 +732,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| فایل | محتوا | | فایل | محتوا |
|---|---| |---|---|
| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس | | `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس |
| `content/seed/questions.json` | ۱۸٬۵۲۲ سؤالِ چهارگزینه‌ای، بعضی با شکل | | `content/seed/questions.json` | ۲۲٬۶۰۲ سؤالِ چهارگزینه‌ای، بعضی با شکل |
- `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است. - `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است.
- سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند. - سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند.

View File

@@ -1,11 +1,12 @@
#!/bin/bash #!/bin/bash
# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند. # روی سرورِ اپ، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه # برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه
# می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود. # می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود.
# این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده # این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده
# (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد. # (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد.
set -euo pipefail set -euo pipefail
LOGS=/home/administrator/mathapp-v2/logs # پوشه‌ی پروژه از جای خودِ اسکریپت (ops/applogs/ → دو پوشه بالاتر)، تا روی هر سروری با هر کاربری درست باشد.
LOGS="$(cd "$(dirname "$0")/../.." && pwd)/logs"
ARCH="$LOGS/archive" ARCH="$LOGS/archive"
mkdir -p "$ARCH" mkdir -p "$ARCH"
chmod 750 "$ARCH" chmod 750 "$ARCH"

View File

@@ -0,0 +1,13 @@
# تدریسینو: سخت‌سازیِ SSH. فقط zareian (صاحبِ محصول) و mathapp-admin (حسابِ مشترکِ سینیور و صاحبِ شرکت) وارد می‌شوند.
# ورود با رمز هنوز باز است (تا رفتن به کلید SSH)؛ fail2ban جلوی حدس زدن را می‌گیرد.
PermitRootLogin no
AllowUsers zareian mathapp-admin
MaxAuthTries 4
MaxSessions 10
LoginGraceTime 2m
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
ClientAliveInterval 300
ClientAliveCountMax 2

View File

@@ -0,0 +1,18 @@
# تدریسینو: تنظیماتِ شبکه‌ی استاندارد (CIS). سرور مسیریاب نیست.
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

View File

@@ -0,0 +1,4 @@
# تدریسینو: آرشیوِ پیوسته‌ی WAL با pgBackRest (رمزنگاری‌شده). archive_timeout یعنی حداکثر ۶۰ ثانیه تغییرِ آرشیونشده.
archive_mode = on
archive_command = 'pgbackrest --stanza=zareian archive-push %p'
archive_timeout = 60

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Gunicorn MathApp (tadrisino API)
After=network.target postgresql@16-zareian.service zareian-redis.service
Wants=postgresql@16-zareian.service zareian-redis.service
[Service]
User=mathapp
Group=www-data
WorkingDirectory=/home/mathapp/mathapp-v2
EnvironmentFile=/home/mathapp/mathapp-v2/.env
# /run/gunicorn بعد از هر ریبوت ساخته می‌شود (روی ۲۴۹ دستی ساخته شده بود و بعد از ریبوت نبود).
RuntimeDirectory=gunicorn
RuntimeDirectoryMode=0750
ExecStart=/home/mathapp/mathapp-v2/venv/bin/gunicorn --bind unix:/run/gunicorn/mathapp.sock config.wsgi:application
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Alias=zareian-mathapp.service

View File

@@ -0,0 +1,17 @@
# روی ۲۱۶: /etc/pgbackrest.conf (root:postgres 640). کلیدِ واقعی فقط روی سرور و در /etc/tadrisino-backup/RECOVERY-KEYS است.
# تدریسینو: بکاپِ رمزنگاری‌شده. مخزنِ محلی /var/lib/pgbackrest؛ نسخه‌ی بیرونی با tadrisino-offsite-sync به ۲۴۷ (فقط فایلِ رمزشده).
[global]
repo1-path=/var/lib/pgbackrest
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<RECOVERY-KEYS، خطِ کلیدِ ۱>
repo1-retention-full=2
compress-type=zst
process-max=2
start-fast=y
log-level-console=info
log-level-file=detail
[zareian]
pg1-path=/var/lib/postgresql/16/zareian
pg1-port=5440
pg1-socket-path=/var/run/postgresql

View File

@@ -0,0 +1,44 @@
#!/bin/bash
# تدریسینو: IPهای شناخته‌شده‌ی مخرب را پیش از اولین تلاش روی همه‌ی پورت‌ها می‌بندد.
# فهرست: FireHOL level1 (Spamhaus DROP/EDROP، DShield، Feodo، bogons)، روزی یک بار (tadrisino-blocklist.timer).
# شبکه‌های داخلی، رنجِ IPِ شرکت و رنج‌های ArvanCloud (از snippetِ nginx) هرگز بسته نمی‌شوند.
# اگر دانلود نشد، آخرین فهرستِ سالم (/var/lib/tadrisino-blocklist/list) دوباره بار می‌شود.
set -euo pipefail
URL=https://iplists.firehol.org/files/firehol_level1.netset
DIR=/var/lib/tadrisino-blocklist; SET=tadrisino-block
mkdir -p "$DIR"
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if curl -fsS -m 90 "$URL" -o "$tmp" && [ "$(grep -c '^[0-9]' "$tmp")" -gt 1000 ]; then
python3 - "$tmp" "$DIR/list" <<'PY'
import ipaddress, re, sys
src, dst = sys.argv[1], sys.argv[2]
keep = ['127.0.0.0/8','10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','100.64.0.0/10','169.254.0.0/16','78.38.35.0/24']
try:
keep += re.findall(r'^allow\s+([0-9./]+);', open('/etc/nginx/snippets/zareian-arvan-only.conf').read(), re.M)
except OSError:
pass
keep = [ipaddress.ip_network(k) for k in keep if ':' not in k]
out, dropped = [], 0
for line in open(src):
line = line.strip()
if not line or line[0] == '#':
continue
n = ipaddress.ip_network(line, strict=False)
if any(n.overlaps(k) for k in keep):
dropped += 1
continue
out.append(str(n))
open(dst, 'w').write('\n'.join(out) + '\n')
print(f'{len(out)} networks, {dropped} skipped (internal/company/Arvan)')
PY
else
echo "download failed; reloading last good list" >&2
fi
[ -s "$DIR/list" ] || { echo "no list available" >&2; exit 1; }
ipset create "$SET" hash:net maxelem 262144 -exist
ipset create "$SET-new" hash:net maxelem 262144 -exist
ipset flush "$SET-new"
sed "s/^/add $SET-new /" "$DIR/list" | ipset restore -exist
ipset swap "$SET-new" "$SET"; ipset destroy "$SET-new"
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
echo "loaded $(ipset list "$SET" -t | sed -n 's/^Number of entries: //p') entries into $SET"

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Tadrisino: block known-malicious IPs (FireHOL level1)
After=network-online.target ufw.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/tadrisino-blocklist
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Tadrisino: refresh malicious-IP blocklist daily
[Timer]
OnCalendar=*-*-* 05:20:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# تدریسینو: دفترِ خرید (payment_paymentevent)، سفارش‌ها و تراکنش‌ها به CSV، هر ۶ ساعت. هر فایل پیش از نوشته‌شدن روی دیسک
# با age برای کلیدِ /etc/tadrisino-backup/age-recipient.txt رمز می‌شود؛ متنِ خوانا هیچ‌جا ذخیره نمی‌شود.
# بازکردن: age -d -i age-identity.txt payment-events-….csv.age (کلید در RECOVERY-KEYS). ۱۴ فایلِ آخرِ هر نوع می‌ماند.
set -euo pipefail
OUT=/var/lib/tadrisino-backup/payment-log
REC=/etc/tadrisino-backup/age-recipient.txt
stamp=$(date -u +%Y%m%dT%H%M%SZ)
q() {
psql -p 5440 -d zareian_mathapp -v ON_ERROR_STOP=1 -Atqc "\\copy ($1) to stdout csv header" | age -R "$REC" -o "$OUT/$2-$stamp.csv.age"
}
q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" payment-events
q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" orders
q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" transactions
chmod 640 "$OUT"/*-"$stamp".csv.age
for kind in payment-events orders transactions; do
ls -1t "$OUT/$kind"-*.csv.age | tail -n +15 | xargs -r rm -f --
done
echo "wrote $OUT/*-$stamp.csv.age"

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-export-payment-events

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
[Timer]
OnCalendar=*-*-* 01,07,13,19:15:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# تدریسینو: نسخه‌ی بیرونیِ بکاپ. فقط فایل‌های رمزشده (مخزنِ pgBackRest با AES-256 و CSVهای age) به ۲۴۷ می‌روند.
# کلیدِ postgres روی ۲۴۷ با rrsync -wo محدود است: فقط نوشتن در ~/zareian-pgbackrest-216، نه خواندن، نه شِل.
# پس از موفقیتِ هر دو، زمانش در offsite-synced-at ثبت می‌شود و /api/health از روی آن عقب‌ماندگی را می‌سنجد.
set -euo pipefail
R=administrator@192.168.200.167
SSH="ssh -i /var/lib/postgresql/.ssh/id_offsite -o BatchMode=yes -o ConnectTimeout=20"
rsync -a --delete -e "$SSH" /var/lib/pgbackrest/ "$R:repo/"
rsync -a --delete -e "$SSH" /var/lib/tadrisino-backup/payment-log/ "$R:payment-log/"
date -u +%s > /var/lib/tadrisino-backup/offsite-synced-at.tmp
mv /var/lib/tadrisino-backup/offsite-synced-at.tmp /var/lib/tadrisino-backup/offsite-synced-at

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-offsite-sync

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
[Timer]
OnCalendar=minutely
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=diff backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
[Timer]
OnCalendar=*-*-* 03:40:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=full backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# تدریسینو: آزمونِ بازیابی از بکاپِ رمزشده (ماهی یک بار، دستی: sudo -u postgres tadrisino-restore-check).
# آخرین بکاپ + همه‌ی WALِ آرشیوشده را در پوشه‌ی موقت بازیابی می‌کند، Postgresِ موقت را روی سوکتِ همان پوشه (بی‌پورتِ شبکه،
# archive_mode=off تا چیزی به مخزن ننویسد) بالا می‌آورد، شمارش‌ها را کنارِ دیتابیسِ اصلی چاپ می‌کند و همه را پاک می‌کند.
set -euo pipefail
BIN=/usr/lib/postgresql/16/bin
work=$(mktemp -d /var/lib/postgresql/restore-check.XXXXXX)
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
pgbackrest --stanza=zareian --pg1-path="$work/data" --type=default --archive-mode=off restore --log-level-console=warn
cat > "$work/data/postgresql.conf" <<CONF
listen_addresses = ''
port = 5499
unix_socket_directories = '$work'
archive_mode = off
shared_buffers = 128MB
CONF
echo 'local all all peer' > "$work/data/pg_hba.conf"
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
for _ in $(seq 120); do
[ "$(psql -h "$work" -p 5499 -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break; sleep 1
done
Q="select 'users', count(*) from accounts_user union all select 'questions', count(*) from content_question
union all select 'orders', count(*) from payment_order union all select 'payment_events', count(*) from payment_paymentevent
union all select 'login_events', count(*) from accounts_loginevent"
echo "restored | live"
paste -d'|' <(psql -h "$work" -p 5499 -d zareian_mathapp -Atc "$Q") <(psql -p 5440 -d zareian_mathapp -Atc "$Q" | cut -d'|' -f2)

View File

@@ -0,0 +1,25 @@
# تدریسینو: SSH در برابرِ ربات‌های حدسِ رمز، پله‌ای تا آدمِ واقعی (اشتباهِ تایپی، کیبوردِ فارسی) گیر نکند.
# ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه بسته؛ هر بارِ تکرار دو برابر (۲۰، ۴۰، ...، حداکثر یک هفته).
# شبکه‌ی داخلیِ شرکت (ادمین‌ها، ۲۴۷، ۲۴۹) هرگز بسته نمی‌شود.
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.200.0/24
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
[sshd]
enabled = true
backend = systemd
mode = aggressive
maxretry = 6
findtime = 10m
bantime = 10m
# فقط رباتِ سمج: پنج بار بسته‌شدن در یک روز → یک هفته روی همه‌ی پورت‌ها.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = iptables-allports
findtime = 1d
maxretry = 5
bantime = 1w

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Redis for zareian-mathapp only
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/bin/redis-server /etc/zareian-redis.conf
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
# تدریسینو، سرور اختصاصی: فقط localhost. max_connections با workers × DB_POOL_MAX_SIZE + 6 در /api/health سنجیده می‌شود.
listen_addresses = '127.0.0.1'
max_connections = 60
shared_buffers = 1GB
effective_cache_size = 4GB

View File

@@ -1,13 +1,21 @@
""" """
اصلاحِ سوال‌های شکل‌دارِ از قبل منتشرشده (seed هیچ‌وقت حذف یا بازنویسی نمی‌کند، پس اصلاح یک ابزارِ جدا می‌خواهد). اصلاحِ محتوای از قبل منتشرشده. seed هیچ‌وقت سوال را حذف یا بازنویسی نمی‌کند و مثال، گام‌ها و شکل‌های درس را
فقط وقتی خالی‌اند پر می‌کند؛ پس اصلاحِ چیزی که روی سرور هست این ابزارِ جدا را می‌خواهد.
python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump) venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
fixups.json: {"delete": [[path, prompt], ...], "blank_title": [[path, prompt], ...]} fixups.json (هر کلید اختیاری است):
delete سوال را کامل پاک می‌کند (پاسخ‌های ثبت‌شده‌اش هم می‌رود؛ فقط برای سوالِ تازه‌منتشرشده) "delete": [[path, prompt], ...] سوال را کامل پاک می‌کند. پاسخ‌های ثبت‌شده‌اش هم پاک می‌شود (CASCADE)،
blank_title عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جوابِ سوال را لو می‌داد) پس فقط برای سوالِ تکراری یا غلط، و پیش از کاربرِ واقعی.
سوال با (path, prompt) شناخته می‌شود؛ هر کدام پیدا نشود فهرست می‌شود، بی‌صدا رد نمی‌شود. "blank_title": [[path, prompt], ...] عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جواب را لو می‌داد).
"lesson": [[path, {"intro"|"example"|"steps"|"figures": مقدار}], ...]
همان فیلدهای داده‌شده‌ی درس را بازنویسی می‌کند.
"question_set": [[path, prompt, {"prompt"|"choices"|"correct"|"figure": مقدار}], ...]
سوال را در جای خودش اصلاح می‌کند. گزینه‌ها به ترتیبِ id عوض می‌شوند و
ردیفشان می‌ماند، چون پاسخ‌های ثبت‌شده به ردیفِ گزینه وصل‌اند؛ تعدادِ
گزینه‌ها باید با سوالِ فعلی یکی باشد.
path برای سوال و درس [پایه، فصل، جزیره، درس] است. هر موردی پیدا نشود یا نخواند فهرست می‌شود، بی‌صدا رد نمی‌شود.
""" """
import json import json
import os import os
@@ -16,64 +24,148 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
LESSON_FIELDS = {'intro': ('introText', 'intro_text'), 'example': ('example', 'example'),
'steps': ('steps', 'steps'), 'figures': ('figures', 'figures')}
QUESTION_FIELDS = {'prompt', 'choices', 'correct', 'figure'}
def key(path, prompt): def key(path, prompt):
return (tuple(path), prompt.strip()) return (tuple(path), prompt.strip())
def _check_fields(fields, allowed, what):
bad = set(fields) - set(allowed)
if bad:
raise SystemExit(f'{what}: فیلدِ ناشناخته {sorted(bad)}')
def apply_seed(fx): def apply_seed(fx):
p = os.path.join(ROOT, 'content', 'seed', 'questions.json') p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
data = json.load(open(p)) data = json.load(open(p))
dele = {key(*x) for x in fx.get('delete', [])} dele = {key(*x) for x in fx.get('delete', [])}
blank = {key(*x) for x in fx.get('blank_title', [])} blank = {key(*x) for x in fx.get('blank_title', [])}
seen_d, seen_b, out = set(), set(), [] qset = {key(path, prompt): f for path, prompt, f in fx.get('question_set', [])}
seen, out, problems = set(), [], []
for q in data['questions']: for q in data['questions']:
k = key(q['path'], q['prompt']) k = key(q['path'], q['prompt'])
if k in dele: if k in dele:
seen_d.add(k) seen.add(('delete', k))
continue continue
if k in blank and q.get('figure'): if k in blank and q.get('figure'):
q['figure']['title'] = '' q['figure']['title'] = ''
seen_b.add(k) seen.add(('blank_title', k))
if k in qset:
f = qset[k]
_check_fields(f, QUESTION_FIELDS, 'question_set')
if 'choices' in f and len(f['choices']) != len(q['choices']):
problems.append(('question_set: تعدادِ گزینه فرق دارد', k))
else:
q.update(f)
seen.add(('question_set', k))
out.append(q) out.append(q)
data['questions'] = out data['questions'] = out
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1) json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
return dele - seen_d, blank - seen_b
cp = os.path.join(ROOT, 'content', 'seed', 'curriculum.json')
cur = json.load(open(cp))
lessons = {}
for g in cur['grades']:
for u in g['units']:
for n in u['nodes']:
for l in n['lessons']:
lessons[(g['title'], u['title'], n['title'], l['title'])] = l
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
l[LESSON_FIELDS[name][0]] = value
seen.add(('lesson', tuple(path)))
json.dump(cur, open(cp, 'w'), ensure_ascii=False, indent=1)
return _missing(fx, seen) + problems
def _missing(fx, seen):
miss = []
for op in ('delete', 'blank_title'):
miss += [(f'{op}: پیدا نشد', key(*x)) for x in fx.get(op, []) if (op, key(*x)) not in seen]
miss += [('question_set: پیدا نشد', key(p, pr)) for p, pr, _ in fx.get('question_set', [])
if ('question_set', key(p, pr)) not in seen]
return miss
def apply_db(fx): def apply_db(fx):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django import django
django.setup() django.setup()
from content.models import Question from django.db import transaction
from content.models import Lesson, Question
lessons = {} lessons = {}
from content.models import Lesson
for l in Lesson.objects.select_related('node__unit__grade'): for l in Lesson.objects.select_related('node__unit__grade'):
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l.pk lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l
miss_d, miss_b = set(), set() problems = []
def find(path, prompt):
l = lessons.get(tuple(path))
return list(Question.objects.filter(lesson=l, prompt=prompt.strip())) if l else []
with transaction.atomic():
for path, prompt in fx.get('delete', []): for path, prompt in fx.get('delete', []):
lid = lessons.get(tuple(path)) qs = find(path, prompt)
qs = Question.objects.filter(lesson_id=lid, prompt=prompt.strip()) if lid else Question.objects.none()
if not qs.exists():
miss_d.add(key(path, prompt))
else:
qs.delete()
for path, prompt in fx.get('blank_title', []):
lid = lessons.get(tuple(path))
qs = list(Question.objects.filter(lesson_id=lid, prompt=prompt.strip())) if lid else []
if not qs: if not qs:
miss_b.add(key(path, prompt)) problems.append(('delete: پیدا نشد', key(path, prompt)))
for q in qs:
q.delete()
for path, prompt in fx.get('blank_title', []):
qs = find(path, prompt)
if not qs:
problems.append(('blank_title: پیدا نشد', key(path, prompt)))
for q in qs: for q in qs:
if q.figure: if q.figure:
q.figure = {**q.figure, 'title': ''} q.figure = {**q.figure, 'title': ''}
q.save(update_fields=['figure']) q.save(update_fields=['figure'])
return miss_d, miss_b for path, prompt, f in fx.get('question_set', []):
_check_fields(f, QUESTION_FIELDS, 'question_set')
qs = find(path, prompt)
if not qs:
problems.append(('question_set: پیدا نشد', key(path, prompt)))
for q in qs:
choices = list(q.choices.order_by('id'))
if 'choices' in f and len(f['choices']) != len(choices):
problems.append(('question_set: تعدادِ گزینه فرق دارد', key(path, prompt)))
continue
correct = f.get('correct')
for i, c in enumerate(choices):
if 'choices' in f:
c.value = f['choices'][i]
if correct is not None:
c.is_correct = (i == correct)
c.save(update_fields=['value', 'is_correct'])
if 'prompt' in f:
q.prompt = f['prompt']
if 'figure' in f:
q.figure = f['figure']
q.save(update_fields=['prompt', 'figure'])
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
setattr(l, LESSON_FIELDS[name][1], value)
l.save(update_fields=[LESSON_FIELDS[n][1] for n in f])
return problems
if __name__ == '__main__': if __name__ == '__main__':
args = [a for a in sys.argv[1:] if a != '--db'] args = [a for a in sys.argv[1:] if a != '--db']
fx = json.load(open(args[0])) fx = json.load(open(args[0]))
miss_d, miss_b = (apply_db if '--db' in sys.argv else apply_seed)(fx) problems = (apply_db if '--db' in sys.argv else apply_seed)(fx)
print(f'deleted/blanked ok; not found: delete={len(miss_d)} blank_title={len(miss_b)}') counts = {k: len(v) for k, v in fx.items()}
for k in list(miss_d)[:5] + list(miss_b)[:5]: print(f'applied {counts}; problems: {len(problems)}')
print(' missing:', k[1][:60]) for what, k in problems[:20]:
print(f' {what}: {" › ".join(k[0])} | {k[1][:60]}')
sys.exit(1 if problems else 0)

File diff suppressed because one or more lines are too long