Initial commit

This commit is contained in:
2026-08-11 16:43:12 +03:30
commit e1d830f6a9
127 changed files with 4454 additions and 0 deletions

0
progress/__init__.py Normal file
View File

30
progress/admin.py Normal file
View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from .models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats
@admin.register(UserStats)
class UserStatsAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'xp', 'coins', 'progress_percent')
@admin.register(LeaderboardEntry)
class LeaderboardEntryAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'period', 'xp_in_period', 'rank')
list_filter = ('period',)
class ActiveDayInline(admin.TabularInline):
model = ActiveDay
extra = 1
@admin.register(StreakRecord)
class StreakRecordAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'current_streak', 'longest_streak')
inlines = [ActiveDayInline]
@admin.register(UserHearts)
class UserHeartsAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'current_hearts', 'max_hearts', 'next_refill_at')

6
progress/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProgressConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'progress'

View File

@@ -0,0 +1,68 @@
# Generated by Django 5.2.17 on 2026-08-10 11:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='LeaderboardEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('period', models.CharField(choices=[('weekly', 'هفتگی'), ('all_time', 'کل زمان')], max_length=10)),
('xp_in_period', models.PositiveIntegerField(default=0)),
('rank', models.PositiveIntegerField()),
],
options={
'ordering': ['rank'],
},
),
migrations.CreateModel(
name='StreakRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('current_streak', models.PositiveIntegerField(default=0)),
('longest_streak', models.PositiveIntegerField(default=0)),
],
),
migrations.CreateModel(
name='UserHearts',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('current_hearts', models.PositiveSmallIntegerField(default=5)),
('max_hearts', models.PositiveSmallIntegerField(default=5)),
('next_refill_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='UserStats',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('xp', models.PositiveIntegerField(default=0)),
('coins', models.PositiveIntegerField(default=0)),
('progress_percent', models.FloatField(default=0)),
],
),
migrations.CreateModel(
name='ActiveDay',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('streak', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='active_days', to='progress.streakrecord')),
],
options={
'ordering': ['date'],
},
),
]

View File

56
progress/models.py Normal file
View File

@@ -0,0 +1,56 @@
from django.db import models
class UserStats(models.Model):
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
xp = models.PositiveIntegerField(default=0)
coins = models.PositiveIntegerField(default=0)
progress_percent = models.FloatField(default=0)
def __str__(self):
return f"stats-user{self.user_id}"
class LeaderboardEntry(models.Model):
PERIOD_CHOICES = [('weekly', 'هفتگی'), ('all_time', 'کل زمان')]
user_id = models.IntegerField(db_index=True)
period = models.CharField(max_length=10, choices=PERIOD_CHOICES)
xp_in_period = models.PositiveIntegerField(default=0)
rank = models.PositiveIntegerField()
class Meta:
ordering = ['rank']
def __str__(self):
return f"lb-{self.period}-user{self.user_id}-rank{self.rank}"
class StreakRecord(models.Model):
user_id = models.IntegerField(unique=True, db_index=True)
current_streak = models.PositiveIntegerField(default=0)
longest_streak = models.PositiveIntegerField(default=0)
def __str__(self):
return f"streak-user{self.user_id}"
class ActiveDay(models.Model):
streak = models.ForeignKey(StreakRecord, on_delete=models.CASCADE, related_name='active_days')
date = models.DateField()
class Meta:
ordering = ['date']
def __str__(self):
return str(self.date)
class UserHearts(models.Model):
user_id = models.IntegerField(unique=True, db_index=True)
current_hearts = models.PositiveSmallIntegerField(default=5)
max_hearts = models.PositiveSmallIntegerField(default=5)
next_refill_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"hearts-user{self.user_id}"

41
progress/serializers.py Normal file
View File

@@ -0,0 +1,41 @@
from rest_framework import serializers
class StreakSummarySerializer(serializers.Serializer):
current = serializers.IntegerField()
longest = serializers.IntegerField()
class UserSummaryResponseSerializer(serializers.Serializer):
userId = serializers.CharField()
name = serializers.CharField()
avatar = serializers.CharField()
gradeId = serializers.IntegerField(allow_null=True)
xp = serializers.IntegerField()
coins = serializers.IntegerField()
streak = StreakSummarySerializer()
subscription = serializers.CharField(allow_null=True)
class StreakCalendarResponseSerializer(serializers.Serializer):
currentStreak = serializers.IntegerField()
longestStreak = serializers.IntegerField()
activeDays = serializers.ListField(child=serializers.DateField())
class LeaderboardEntrySerializer(serializers.Serializer):
rank = serializers.IntegerField()
userId = serializers.CharField()
name = serializers.CharField()
xpInPeriod = serializers.IntegerField()
class LeaderboardResponseSerializer(serializers.Serializer):
period = serializers.ChoiceField(choices=['weekly', 'all_time'])
entries = LeaderboardEntrySerializer(many=True)
class UserHeartsResponseSerializer(serializers.Serializer):
currentHearts = serializers.IntegerField()
maxHearts = serializers.IntegerField()
nextRefillAt = serializers.DateTimeField(allow_null=True)

3
progress/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
progress/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from .views import LeaderboardView, StreakCalendarView, UserHeartsView, UserSummaryView
urlpatterns = [
path('user/summary', UserSummaryView.as_view(), name='user-summary'),
path('user/streak/calendar', StreakCalendarView.as_view(), name='streak-calendar'),
path('leaderboard', LeaderboardView.as_view(), name='leaderboard'),
path('user/hearts', UserHeartsView.as_view(), name='user-hearts'),
]

109
progress/views.py Normal file
View File

@@ -0,0 +1,109 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
from .serializers import (
LeaderboardResponseSerializer,
StreakCalendarResponseSerializer,
UserHeartsResponseSerializer,
UserSummaryResponseSerializer,
)
class UserSummaryView(APIView):
"""GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication
stats = UserStats.objects.filter(user_id=user.id).first()
streak = StreakRecord.objects.filter(user_id=user.id).first()
data = {
'userId': str(user.id),
'name': user.name,
'avatar': user.avatar,
'gradeId': user.grade_id,
'xp': stats.xp if stats else 0,
'coins': stats.coins if stats else 0,
'streak': {
'current': streak.current_streak if streak else 0,
'longest': streak.longest_streak if streak else 0,
},
# اپ subscription هنوز در این فاز پیاده‌سازی نشده؛ فعلاً null.
'subscription': None,
}
return Response(UserSummaryResponseSerializer(data).data)
class StreakCalendarView(APIView):
"""GET /api/user/streak/calendar — تقویم کامل روزهای فعال"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
user = request.user
streak = StreakRecord.objects.filter(user_id=user.id).first()
data = {
'currentStreak': streak.current_streak if streak else 0,
'longestStreak': streak.longest_streak if streak else 0,
'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [],
}
return Response(StreakCalendarResponseSerializer(data).data)
class LeaderboardView(APIView):
"""GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه"""
@extend_schema(
parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False,
enum=['weekly', 'all_time'])],
responses=LeaderboardResponseSerializer,
tags=['Progress & Gamification (progress)'],
)
def get(self, request):
period = request.query_params.get('period', 'weekly')
entries = LeaderboardEntry.objects.filter(period=period).order_by('rank')[:50]
# نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی).
from accounts.models import User as AccountUser
user_ids = [e.user_id for e in entries]
names = {u.id: u.name for u in AccountUser.objects.filter(id__in=user_ids)}
data = {
'period': period,
'entries': [
{
'rank': e.rank,
'userId': str(e.user_id),
'name': names.get(e.user_id, ''),
'xpInPeriod': e.xp_in_period,
}
for e in entries
],
}
return Response(LeaderboardResponseSerializer(data).data)
class UserHeartsView(APIView):
"""GET /api/user/hearts — وضعیت فعلی جان‌ها (هارت)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
user = request.user
hearts, _ = UserHearts.objects.get_or_create(user_id=user.id)
data = {
'currentHearts': hearts.current_hearts,
'maxHearts': hearts.max_hearts,
'nextRefillAt': hearts.next_refill_at,
}
return Response(UserHeartsResponseSerializer(data).data)