add gamification characters (Duolingo-style mascot catalog)

New progress.Character model: an admin-managed catalog (same pattern as
subscription.Plan) of selectable mascots, each unlocked once the user's
XP crosses unlock_xp. No separate ownership table yet — unlocking is a
pure function of UserStats.xp, kept intentionally minimal since this is
the first of an incremental rollout the senior asked for.

- GET /api/characters — catalog with per-user unlocked/selected flags
- POST /api/characters/select — picks an active character, rejecting
  ones the user hasn't unlocked yet
- UserStats.selected_character_id tracks the pick
- seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion)
  for local/staging testing

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 13:53:17 +03:30
parent b5476e1c5d
commit 37a1741e0e
9 changed files with 266 additions and 6 deletions

View File

@@ -11,6 +11,10 @@ class UserStats(models.Model):
xp = models.PositiveIntegerField(default=0)
coins = models.PositiveIntegerField(default=0)
progress_percent = models.FloatField(default=0)
# ارجاع سبک به Character؛ کاراکترِ انتخابیِ کاربر (مثلِ ماسکاتِ Duolingo).
# می‌تونه به کاراکترِ هنوز باز‌نشده اشاره نکنه — CharacterSelectView قبل از
# ست‌کردن چک می‌کنه unlock_xp <= xp.
selected_character_id = models.IntegerField(null=True, blank=True)
def __str__(self):
return f"stats-user{self.user_id}"
@@ -92,3 +96,27 @@ class UserHearts(models.Model):
self.current_hearts -= 1
if self.next_refill_at is None and self.current_hearts < self.max_hearts:
self.next_refill_at = timezone.now() + HEART_REFILL_INTERVAL
class Character(models.Model):
"""
کاراکترهای گیمیفیکیشن (مثلِ ماسکات‌های Duolingo) — یک کاتالوگِ ثابت که از
پنلِ ادمین مدیریت می‌شه (دقیقاً مثلِ subscription.Plan)، نه یک چیزی که
کاربر خودش می‌سازه. فعلاً باز/بسته‌بودن فقط تابعی از XP کاربره (بدونِ
جدولِ جداگانه‌ی مالکیت)؛ اگه بعداً «خریدن با coin» یا شرطِ دیگه‌ای لازم
شد، همین‌جا اضافه می‌شه.
"""
slug = models.SlugField(max_length=50, unique=True)
name = models.CharField(max_length=50)
# کلیدی که فرانت به تصویر/انیمیشنِ واقعی نگاشت می‌ده — همون الگویِ
# content.Lesson.mascot_animation و accounts.User.avatar؛ فعلاً آپلودِ
# asset واقعی نداریم.
icon = models.CharField(max_length=100)
unlock_xp = models.PositiveIntegerField(default=0)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['order', 'unlock_xp']
def __str__(self):
return self.name