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:
2026-09-15 23:18:05 +03:30
parent 61b7c91b02
commit 831e656fa3
32 changed files with 1036 additions and 77 deletions
View File
+46
View File
@@ -0,0 +1,46 @@
from django.contrib import admin
from django.db.models import Count
from .models import Review, ReviewLike
class ReviewLikeInline(admin.TabularInline):
model = ReviewLike
extra = 0
readonly_fields = ("user", "created_at")
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
list_display = ("user", "body_preview", "like_count", "is_approved", "created_at")
list_filter = ("is_approved", "created_at")
list_editable = ("is_approved",)
search_fields = ("user__phone", "user__name", "body")
readonly_fields = ("user", "created_at", "updated_at")
inlines = [ReviewLikeInline]
actions = ["approve_reviews", "reject_reviews"]
def get_queryset(self, request):
return super().get_queryset(request).annotate(_like_count=Count("likes"))
@admin.display(description="متن نظر")
def body_preview(self, obj: Review) -> str:
return obj.body if len(obj.body) <= 60 else f"{obj.body[:60]}…"
@admin.display(description="تعداد لایک", ordering="_like_count")
def like_count(self, obj: Review) -> int:
return obj._like_count
@admin.action(description="تاییدِ نظرهای انتخاب‌شده")
def approve_reviews(self, request, queryset):
updated = queryset.update(is_approved=True)
self.message_user(request, f"{updated} نظر تایید شد.")
@admin.action(description="رد کردنِ نظرهای انتخاب‌شده")
def reject_reviews(self, request, queryset):
updated = queryset.update(is_approved=False)
self.message_user(request, f"{updated} نظر رد شد.")
View File
+53
View File
@@ -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
+88
View File
@@ -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})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews"
verbose_name = "نظرات کاربران"
+47
View File
@@ -0,0 +1,47 @@
# Generated by Django 5.1.15 on 2026-09-15 00:47
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField(max_length=1000, verbose_name='متن نظر')),
('is_approved', models.BooleanField(default=False, verbose_name='تاییدشده')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'نظر',
'verbose_name_plural': 'نظرات',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ReviewLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='reviews.review')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_likes', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'لایک نظر',
'verbose_name_plural': 'لایک\u200cهای نظر',
'constraints': [models.UniqueConstraint(fields=('review', 'user'), name='unique_review_like')],
},
),
]
View File
+50
View File
@@ -0,0 +1,50 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# «نظرات» — یک دیوارِ بازخوردِ ساده، عمداً بدونِ گفت‌وگو: هر کاربر فقط یک
# نظر دارد (نوشتنِ دوباره همان نظر را جایگزین می‌کند، نه یک ردیفِ تازه)،
# نظرات فقط لایک می‌خورند (نه ریپلای)، و فقط بعد از تاییدِ ادمین در فهرستِ
# عمومی دیده می‌شوند — نوشتن/ویرایشِ دوباره وضعیتِ تایید را برمی‌گرداند به
# «در انتظار» تا کسی نتواند یک نظرِ تاییدشده را بی‌بازبینی به چیزِ دیگری
# تغییر بدهد.
class Review(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review"
)
body = models.TextField(max_length=1000, verbose_name=_("متن نظر"))
is_approved = models.BooleanField(default=False, verbose_name=_("تاییدشده"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("نظر")
verbose_name_plural = _("نظرات")
ordering = ["-created_at"]
def __str__(self):
return f"{self.user.phone} — {self.body[:30]}"
@property
def like_count(self) -> int:
return self.likes.count()
class ReviewLike(models.Model):
review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name="likes")
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_likes"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("لایک نظر")
verbose_name_plural = _("لایک‌های نظر")
constraints = [
models.UniqueConstraint(fields=["review", "user"], name="unique_review_like")
]
def __str__(self):
return f"{self.user.phone} ← {self.review_id}"
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .api.views import MyReviewView, ReviewLikeToggleView, ReviewListView
app_name = "reviews"
urlpatterns = [
path("", ReviewListView.as_view(), name="list"),
path("mine/", MyReviewView.as_view(), name="mine"),
path("<int:pk>/like/", ReviewLikeToggleView.as_view(), name="like"),
]