Initial commit
This commit is contained in:
247
content/views.py
Normal file
247
content/views.py
Normal file
@@ -0,0 +1,247 @@
|
||||
from django.db.models import Sum
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from progress.models import UserHearts, UserStats
|
||||
|
||||
from .models import Choice, Grade, Lesson, Question, UserAnswer, UserLessonCompletion
|
||||
from .serializers import (
|
||||
AnswerRequestSerializer,
|
||||
AnswerResponseSerializer,
|
||||
GradesListResponseSerializer,
|
||||
HomeResponseSerializer,
|
||||
LessonCompleteRequestSerializer,
|
||||
LessonCompleteResponseSerializer,
|
||||
LessonIntroResponseSerializer,
|
||||
LessonQuestionsResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
class GradesListView(APIView):
|
||||
"""GET /api/grades — لیست پایهها"""
|
||||
|
||||
@extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)'])
|
||||
def get(self, request):
|
||||
grades = Grade.objects.all().order_by('id')
|
||||
data = {'grades': [
|
||||
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in grades
|
||||
]}
|
||||
return Response(GradesListResponseSerializer(data).data)
|
||||
|
||||
|
||||
def _node_status(node, user_id, is_first_unlocked_candidate):
|
||||
lesson_ids = list(node.lessons.values_list('id', flat=True))
|
||||
if not lesson_ids:
|
||||
return 'locked'
|
||||
completed_count = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson_id__in=lesson_ids
|
||||
).values('lesson_id').distinct().count()
|
||||
if completed_count >= len(lesson_ids):
|
||||
return 'completed'
|
||||
if completed_count > 0 or is_first_unlocked_candidate:
|
||||
return 'unlocked'
|
||||
return 'locked'
|
||||
|
||||
|
||||
class HomeView(APIView):
|
||||
"""GET /api/home?userId&grade — نقشهی مهارتها + آمار (ادغامشده)"""
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter('userId', OpenApiTypes.INT, required=True),
|
||||
OpenApiParameter('grade', OpenApiTypes.INT, required=False),
|
||||
],
|
||||
responses=HomeResponseSerializer,
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def get(self, request):
|
||||
user_id = request.query_params.get('userId')
|
||||
grade_id = request.query_params.get('grade')
|
||||
if not user_id:
|
||||
return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user_id = int(user_id)
|
||||
|
||||
units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else []
|
||||
|
||||
units_payload = []
|
||||
for unit in units_qs:
|
||||
nodes = list(unit.nodes.all())
|
||||
node_payload = []
|
||||
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
||||
for node in nodes:
|
||||
node_status = _node_status(node, user_id, unlock_next)
|
||||
if node_status != 'completed':
|
||||
unlock_next = False
|
||||
node_payload.append({'id': str(node.id), 'title': node.title, 'status': node_status})
|
||||
units_payload.append({'id': str(unit.id), 'title': unit.title, 'nodes': node_payload})
|
||||
|
||||
stats = UserStats.objects.filter(user_id=user_id).first()
|
||||
stats_payload = {
|
||||
'progressPercent': stats.progress_percent if stats else 0,
|
||||
'xp': stats.xp if stats else 0,
|
||||
'coins': stats.coins if stats else 0,
|
||||
}
|
||||
|
||||
data = {'units': units_payload, 'stats': stats_payload}
|
||||
return Response(HomeResponseSerializer(data).data)
|
||||
|
||||
|
||||
class LessonIntroView(APIView):
|
||||
"""GET /api/lessons/{id}/intro"""
|
||||
|
||||
@extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)'])
|
||||
def get(self, request, lesson_id):
|
||||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||||
data = {
|
||||
'lessonId': str(lesson.id),
|
||||
'title': lesson.title,
|
||||
'introText': lesson.intro_text,
|
||||
'mascotAnimation': lesson.mascot_animation,
|
||||
}
|
||||
return Response(LessonIntroResponseSerializer(data).data)
|
||||
|
||||
|
||||
class LessonQuestionsView(APIView):
|
||||
"""GET /api/lessons/{id}/questions"""
|
||||
|
||||
@extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)'])
|
||||
def get(self, request, lesson_id):
|
||||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||||
questions = lesson.questions.prefetch_related('choices').all()
|
||||
data = {'questions': [
|
||||
{
|
||||
'id': str(q.id),
|
||||
'type': q.type,
|
||||
'prompt': q.prompt,
|
||||
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
|
||||
}
|
||||
for q in questions
|
||||
]}
|
||||
return Response(LessonQuestionsResponseSerializer(data).data)
|
||||
|
||||
|
||||
class QuestionAnswerView(APIView):
|
||||
"""POST /api/questions/{id}/answer — ثبت پاسخ"""
|
||||
|
||||
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده میشد که به
|
||||
# کلاینت اجازه میداد بهجای هر کاربر دیگری پاسخ ثبت کنه (heart/xp کاربر
|
||||
# دلخواه رو تغییر بده). حالا auth اجباریه و همیشه از request.user.id
|
||||
# (که از توکن معتبر استخراج شده) استفاده میشه.
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
request=AnswerRequestSerializer,
|
||||
responses=AnswerResponseSerializer,
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def post(self, request, question_id):
|
||||
question = get_object_or_404(Question, pk=question_id)
|
||||
serializer = AnswerRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
selected_option_id = serializer.validated_data['selectedOptionId']
|
||||
|
||||
choice = get_object_or_404(Choice, pk=selected_option_id, question=question)
|
||||
correct_choice = question.choices.filter(is_correct=True).first()
|
||||
|
||||
user_id = request.user.id
|
||||
xp_earned = 10 if choice.is_correct else 0
|
||||
|
||||
UserAnswer.objects.create(
|
||||
user_id=user_id,
|
||||
question=question,
|
||||
choice=choice,
|
||||
correct=choice.is_correct,
|
||||
xp_earned=xp_earned,
|
||||
)
|
||||
if not choice.is_correct:
|
||||
hearts, _ = UserHearts.objects.get_or_create(user_id=user_id)
|
||||
if hearts.current_hearts > 0:
|
||||
hearts.current_hearts -= 1
|
||||
hearts.save(update_fields=['current_hearts'])
|
||||
hearts_remaining = hearts.current_hearts
|
||||
else:
|
||||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||||
hearts_remaining = hearts.current_hearts if hearts else 5
|
||||
|
||||
data = {
|
||||
'correct': choice.is_correct,
|
||||
'correctOptionId': str(correct_choice.id) if correct_choice else '',
|
||||
'xpEarned': xp_earned,
|
||||
'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5,
|
||||
}
|
||||
return Response(AnswerResponseSerializer(data).data)
|
||||
|
||||
|
||||
class LessonCompleteView(APIView):
|
||||
"""POST /api/lessons/{id}/complete — اتمام درس + آمار جدید"""
|
||||
|
||||
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده میشد که به
|
||||
# کلاینت اجازه میداد بهجای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
|
||||
# اجباریه و همیشه از request.user.id استفاده میشه.
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
request=LessonCompleteRequestSerializer,
|
||||
responses=LessonCompleteResponseSerializer,
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def post(self, request, lesson_id):
|
||||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||||
serializer = LessonCompleteRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
correct_count = serializer.validated_data['correctCount']
|
||||
total_questions = serializer.validated_data['totalQuestions'] or 1
|
||||
|
||||
ratio = correct_count / total_questions
|
||||
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
|
||||
potential_xp = correct_count * 10
|
||||
potential_coins = stars * 5
|
||||
|
||||
user_id = request.user.id
|
||||
|
||||
# ⚠️ اصلاح: تکمیل هر درس یکبارمصرفه — دفعات بعدیِ تکمیل همون درس
|
||||
# نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار
|
||||
# تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه میکرد).
|
||||
is_first_completion = not UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson=lesson
|
||||
).exists()
|
||||
xp_earned = potential_xp if is_first_completion else 0
|
||||
coins_earned = potential_coins if is_first_completion else 0
|
||||
|
||||
UserLessonCompletion.objects.create(
|
||||
user_id=user_id,
|
||||
lesson=lesson,
|
||||
correct_count=correct_count,
|
||||
stars=stars,
|
||||
xp_earned=xp_earned,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
stats, _ = UserStats.objects.get_or_create(user_id=user_id)
|
||||
stats.xp += xp_earned
|
||||
stats.coins += coins_earned
|
||||
|
||||
total_lessons = Lesson.objects.count() or 1
|
||||
completed_lessons = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id
|
||||
).values('lesson_id').distinct().count()
|
||||
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
|
||||
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
||||
|
||||
stats_payload = {
|
||||
'progressPercent': stats.progress_percent,
|
||||
'xp': stats.xp,
|
||||
'coins': stats.coins,
|
||||
}
|
||||
|
||||
data = {
|
||||
'stars': stars,
|
||||
'xpEarned': xp_earned,
|
||||
'coinsEarned': coins_earned,
|
||||
'stats': stats_payload,
|
||||
}
|
||||
return Response(LessonCompleteResponseSerializer(data).data)
|
||||
Reference in New Issue
Block a user