Initial commit - clean project structure

This commit is contained in:
2026-08-18 16:48:15 +03:30
commit 331148037e
147 changed files with 5543 additions and 0 deletions

0
support/__init__.py Normal file
View File

52
support/admin.py Normal file
View File

@@ -0,0 +1,52 @@
from django.contrib import admin
from .models import BugReport, Ticket, TicketMessage
class TicketMessageInline(admin.TabularInline):
model = TicketMessage
extra = 0
readonly_fields = ('created_at',)
@admin.register(BugReport)
class BugReportAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'title', 'status', 'linked_ticket_id', 'created_at')
list_filter = ('status',)
search_fields = ('title',)
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'subject', 'category', 'status', 'priority', 'created_at')
list_filter = ('status', 'priority', 'category')
search_fields = ('subject',)
inlines = [TicketMessageInline]
@admin.register(TicketMessage)
class TicketMessageAdmin(admin.ModelAdmin):
list_display = ('id', 'ticket', 'sender', 'created_at')
list_filter = ('sender',)
def save_model(self, request, obj, form, change):
"""
طبق سند معماری، ارتباط Support→Notifications فقط از طریق رویداد
TicketReplied‌ه، نه رفرنس مدل. چون پاسخ پشتیبانی (sender='support')
از پنل ادمین ثبت می‌شه، رویداد دقیقاً همین‌جا ساطع می‌شه — همون
الگوی notify() که در payment/views.py (PaymentWebhookView) هنگام
پرداخت موفق (status=paid) صدا زده می‌شه.
"""
is_new = obj.pk is None
super().save_model(request, obj, form, change)
if is_new and obj.sender == 'support':
from notifications.services import notify
notify(
user_id=obj.ticket.user_id,
type='ticket_replied',
title='پاسخ جدید برای تیکت شما',
body=obj.text[:200],
related_id=obj.ticket_id,
)

6
support/apps.py Normal file
View File

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

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BugReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('title', models.CharField(max_length=150)),
('description', models.TextField()),
('screenshot_url', models.URLField(blank=True)),
('status', models.CharField(choices=[('open', 'باز'), ('resolved', 'برطرف‌شده')], default='open', max_length=15)),
('linked_ticket_id', models.IntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('subject', models.CharField(max_length=150)),
('category', models.CharField(max_length=30)),
('status', models.CharField(choices=[('open', 'باز'), ('in_progress', 'در حال بررسی'), ('closed', 'بسته')], default='open', max_length=15)),
('priority', models.CharField(choices=[('low', 'کم'), ('normal', 'عادی'), ('high', 'بالا')], default='normal', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TicketMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sender', models.CharField(choices=[('user', 'کاربر'), ('support', 'پشتیبانی')], max_length=10)),
('text', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='support.ticket')),
],
options={
'ordering': ['created_at'],
},
),
]

View File

54
support/models.py Normal file
View File

@@ -0,0 +1,54 @@
from django.db import models
class BugReport(models.Model):
STATUS_CHOICES = [('open', 'باز'), ('resolved', 'برطرف‌شده')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
title = models.CharField(max_length=150)
description = models.TextField()
screenshot_url = models.URLField(blank=True)
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open')
# ارجاع سبک اختیاری به Ticket (نه FK واقعی)؛ یعنی این باگ می‌تونه بعداً
# به یک تیکت اسکیل بشه، ولی جوینی بین این دو مدل اجباری نیست.
linked_ticket_id = models.IntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"bug-{self.pk}-{self.title}"
class Ticket(models.Model):
STATUS_CHOICES = [('open', 'باز'), ('in_progress', 'در حال بررسی'), ('closed', 'بسته')]
PRIORITY_CHOICES = [('low', 'کم'), ('normal', 'عادی'), ('high', 'بالا')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
subject = models.CharField(max_length=150)
category = models.CharField(max_length=30)
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open')
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='normal')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"ticket-{self.pk}-{self.subject}"
class TicketMessage(models.Model):
SENDER_CHOICES = [('user', 'کاربر'), ('support', 'پشتیبانی')]
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name='messages')
sender = models.CharField(max_length=10, choices=SENDER_CHOICES)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"tmsg-{self.pk}-ticket{self.ticket_id}-{self.sender}"

45
support/serializers.py Normal file
View File

@@ -0,0 +1,45 @@
from rest_framework import serializers
class BugReportCreateRequestSerializer(serializers.Serializer):
title = serializers.CharField(max_length=150)
description = serializers.CharField()
screenshotUrl = serializers.URLField(required=False, allow_blank=True)
class BugReportCreateResponseSerializer(serializers.Serializer):
bugReportId = serializers.CharField()
status = serializers.CharField()
class TicketCreateRequestSerializer(serializers.Serializer):
subject = serializers.CharField(max_length=150)
category = serializers.CharField(max_length=30)
priority = serializers.ChoiceField(choices=['low', 'normal', 'high'], required=False)
class TicketCreateResponseSerializer(serializers.Serializer):
ticketId = serializers.CharField()
status = serializers.CharField()
class TicketMessageItemSerializer(serializers.Serializer):
id = serializers.CharField()
sender = serializers.CharField()
text = serializers.CharField()
class TicketDetailResponseSerializer(serializers.Serializer):
ticketId = serializers.CharField()
subject = serializers.CharField()
status = serializers.CharField()
messages = TicketMessageItemSerializer(many=True)
class TicketMessageCreateRequestSerializer(serializers.Serializer):
text = serializers.CharField()
class TicketMessageCreateResponseSerializer(serializers.Serializer):
messageId = serializers.CharField()
success = serializers.BooleanField()

3
support/tests.py Normal file
View File

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

19
support/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from .views import (
BugReportCreateView,
TicketCreateView,
TicketDetailView,
TicketMessageCreateView,
)
urlpatterns = [
path('support/bug-reports', BugReportCreateView.as_view(), name='support-bug-report-create'),
path('support/tickets', TicketCreateView.as_view(), name='support-ticket-create'),
path('support/tickets/<int:ticket_id>', TicketDetailView.as_view(), name='support-ticket-detail'),
path(
'support/tickets/<int:ticket_id>/messages',
TicketMessageCreateView.as_view(),
name='support-ticket-message-create',
),
]

126
support/views.py Normal file
View File

@@ -0,0 +1,126 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import BugReport, Ticket, TicketMessage
from .serializers import (
BugReportCreateRequestSerializer,
BugReportCreateResponseSerializer,
TicketCreateRequestSerializer,
TicketCreateResponseSerializer,
TicketDetailResponseSerializer,
TicketMessageCreateRequestSerializer,
TicketMessageCreateResponseSerializer,
)
class BugReportCreateView(APIView):
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=BugReportCreateRequestSerializer,
responses=BugReportCreateResponseSerializer,
tags=['Support (support)'],
)
def post(self, request):
serializer = BugReportCreateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
bug = BugReport.objects.create(
user_id=request.user.id,
title=v['title'],
description=v['description'],
screenshot_url=v.get('screenshotUrl', ''),
)
data = {'bugReportId': str(bug.pk), 'status': bug.status}
return Response(BugReportCreateResponseSerializer(data).data)
class TicketCreateView(APIView):
"""POST /api/support/tickets — ایجاد تیکت"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=TicketCreateRequestSerializer,
responses=TicketCreateResponseSerializer,
tags=['Support (support)'],
)
def post(self, request):
serializer = TicketCreateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
ticket = Ticket.objects.create(
user_id=request.user.id,
subject=v['subject'],
category=v['category'],
priority=v.get('priority', 'normal'),
)
data = {'ticketId': str(ticket.pk), 'status': ticket.status}
return Response(TicketCreateResponseSerializer(data).data)
class TicketDetailView(APIView):
"""
GET /api/support/tickets/{id} — جزئیات و پیام‌های تیکت
فقط تیکت متعلق به همون کاربر برمی‌گرده (فیلتر با user_id، دقیقاً
همون الگوی محدودسازی به مالک که در progress/views.py برای
UserStats و در ai_tutor/views.py برای Conversation استفاده شده).
"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=TicketDetailResponseSerializer, tags=['Support (support)'])
def get(self, request, ticket_id):
ticket = get_object_or_404(Ticket, pk=ticket_id, user_id=request.user.id)
messages = [
{'id': str(m.pk), 'sender': m.sender, 'text': m.text}
for m in ticket.messages.all()
]
data = {
'ticketId': str(ticket.pk),
'subject': ticket.subject,
'status': ticket.status,
'messages': messages,
}
return Response(TicketDetailResponseSerializer(data).data)
class TicketMessageCreateView(APIView):
"""
POST /api/support/tickets/{id}/messages — ارسال پیام در تیکت
این Endpoint سمت کاربره، پس sender همیشه 'user' ثبت می‌شه. پاسخ
پشتیبانی (sender='support') از پنل ادمین ثبت می‌شه و طبق سند معماری
از همون‌جا رویداد TicketReplied برای Notifications ساطع می‌شه
(پیاده‌سازی در support/admin.py، نه اینجا).
"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=TicketMessageCreateRequestSerializer,
responses=TicketMessageCreateResponseSerializer,
tags=['Support (support)'],
)
def post(self, request, ticket_id):
ticket = get_object_or_404(Ticket, pk=ticket_id, user_id=request.user.id)
serializer = TicketMessageCreateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
message = TicketMessage.objects.create(ticket=ticket, sender='user', text=v['text'])
data = {'messageId': str(message.pk), 'success': True}
return Response(TicketMessageCreateResponseSerializer(data).data)