Initial commit
This commit is contained in:
0
progress/__init__.py
Normal file
0
progress/__init__.py
Normal file
30
progress/admin.py
Normal file
30
progress/admin.py
Normal 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
6
progress/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProgressConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'progress'
|
||||
68
progress/migrations/0001_initial.py
Normal file
68
progress/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
progress/migrations/__init__.py
Normal file
0
progress/migrations/__init__.py
Normal file
56
progress/models.py
Normal file
56
progress/models.py
Normal 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
41
progress/serializers.py
Normal 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
3
progress/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
progress/urls.py
Normal file
10
progress/urls.py
Normal 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
109
progress/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user