Fix OTP/SMS, ledger accounting, order tracking, and add reviews app
- Add MessageTemplate.runtime_field_labels so mobile's per-field execution-time labels are actually persisted (were silently dropped) - Fix AdvanceOrderStageView reading the wrong extra-input key, and be honest about SMS send failures instead of always claiming success - Add admin gift-gem SMS notification via Kavenegar - Post real double-entry Transactions for plain (non-invoice) ledger entries so trial balance, the معین screen, and CSV/XLSX exports actually include everyday income/expense entries; backfill existing historical entries via data migration - Rewrite CSV ledger export to produce an actual دفتر معین structure (per-account running balance) instead of a flat row dump - Add invoice percent/promotional discounts, tax presets, ledger discount-percent handling, product SKU/category/expiry fields, and manual ledger-book archiving (replacing auto-archive on new book) - Add reviews app (models, API, admin) for the comments/reviews feature - Update tests for the above; fix two stale assertions left over from earlier work in this branch (معین sheet name, book auto-close)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.reviews.models import Review
|
||||
|
||||
|
||||
def _display_name(user) -> str:
|
||||
if user.name:
|
||||
return user.name
|
||||
# حریمِ خصوصی: برای کاربرِ بینام، فقط ۴ رقمِ آخرِ شماره نشان داده میشود.
|
||||
return f"کاربر ...{user.phone[-4:]}"
|
||||
|
||||
|
||||
class ReviewSerializer(serializers.ModelSerializer):
|
||||
user_display = serializers.SerializerMethodField()
|
||||
like_count = serializers.IntegerField(read_only=True)
|
||||
liked_by_me = serializers.SerializerMethodField()
|
||||
is_mine = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Review
|
||||
fields = [
|
||||
"id", "user_display", "body", "is_approved",
|
||||
"like_count", "liked_by_me", "is_mine", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["is_approved", "created_at", "updated_at"]
|
||||
|
||||
def get_user_display(self, obj: Review) -> str:
|
||||
return _display_name(obj.user)
|
||||
|
||||
def get_liked_by_me(self, obj: Review) -> bool:
|
||||
request = self.context.get("request")
|
||||
if request is None or not request.user.is_authenticated:
|
||||
return False
|
||||
# get_liked_ids (اگر در context باشد) از یک کوئریِ batch پر میشود تا
|
||||
# برای فهرست، بهازای هر نظر یک کوئریِ جدا نزنیم.
|
||||
liked_ids = self.context.get("liked_ids")
|
||||
if liked_ids is not None:
|
||||
return obj.id in liked_ids
|
||||
return obj.likes.filter(user=request.user).exists()
|
||||
|
||||
def get_is_mine(self, obj: Review) -> bool:
|
||||
request = self.context.get("request")
|
||||
return bool(request and request.user.is_authenticated and obj.user_id == request.user.id)
|
||||
|
||||
|
||||
class ReviewWriteSerializer(serializers.Serializer):
|
||||
body = serializers.CharField(max_length=1000)
|
||||
|
||||
def validate_body(self, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise serializers.ValidationError("متنِ نظر نمیتواند خالی باشد.")
|
||||
return value
|
||||
@@ -0,0 +1,88 @@
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Count
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.reviews.models import Review, ReviewLike
|
||||
|
||||
from .serializers import ReviewSerializer, ReviewWriteSerializer
|
||||
|
||||
|
||||
class ReviewListView(APIView):
|
||||
"""فهرستِ عمومیِ نظراتِ تاییدشده — پرلایکترینها اول."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
reviews = (
|
||||
Review.objects.filter(is_approved=True)
|
||||
.select_related("user")
|
||||
.annotate(like_count=Count("likes"))
|
||||
.order_by("-like_count", "-created_at")
|
||||
)
|
||||
liked_ids = set(
|
||||
ReviewLike.objects.filter(user=request.user, review__in=reviews).values_list(
|
||||
"review_id", flat=True
|
||||
)
|
||||
)
|
||||
serializer = ReviewSerializer(
|
||||
reviews, many=True, context={"request": request, "liked_ids": liked_ids}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class MyReviewView(APIView):
|
||||
"""نظرِ خودِ کاربر — صرفنظر از تایید یا نبودنِ آن. یک کاربر همیشه دقیقا
|
||||
صفر یا یک نظر دارد؛ نوشتنِ دوباره همان نظر را جایگزین و تاییدش را
|
||||
ریست میکند."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
review = Review.objects.filter(user=request.user).first()
|
||||
if review is None:
|
||||
return Response(None)
|
||||
return Response(ReviewSerializer(review, context={"request": request}).data)
|
||||
|
||||
def patch(self, request):
|
||||
serializer = ReviewWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
review, _created = Review.objects.update_or_create(
|
||||
user=request.user,
|
||||
defaults={
|
||||
"body": serializer.validated_data["body"],
|
||||
"is_approved": False,
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
ReviewSerializer(review, context={"request": request}).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request):
|
||||
Review.objects.filter(user=request.user).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ReviewLikeToggleView(APIView):
|
||||
"""لایک/آنلایکِ یک نظرِ تاییدشده — لایکِ نظرِ تاییدنشدهی خودِ کسی معنی
|
||||
ندارد، چون در فهرستِ عمومی اصلا دیده نمیشود."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, pk):
|
||||
review = Review.objects.filter(pk=pk, is_approved=True).first()
|
||||
if review is None:
|
||||
raise NotFound("نظر یافت نشد.")
|
||||
deleted, _ = ReviewLike.objects.filter(review=review, user=request.user).delete()
|
||||
if not deleted:
|
||||
try:
|
||||
ReviewLike.objects.create(review=review, user=request.user)
|
||||
except IntegrityError:
|
||||
pass # درخواستِ همزمانِ دوم — بیضرر، نتیجه هرحال «لایکشده» است.
|
||||
like_count = review.likes.count()
|
||||
liked_by_me = review.likes.filter(user=request.user).exists()
|
||||
return Response({"like_count": like_count, "liked_by_me": liked_by_me})
|
||||
Reference in New Issue
Block a user