Initial commit - clean project structure
This commit is contained in:
0
support/__init__.py
Normal file
0
support/__init__.py
Normal file
52
support/admin.py
Normal file
52
support/admin.py
Normal 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
6
support/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SupportConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'support'
|
||||
59
support/migrations/0001_initial.py
Normal file
59
support/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
support/migrations/__init__.py
Normal file
0
support/migrations/__init__.py
Normal file
54
support/models.py
Normal file
54
support/models.py
Normal 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
45
support/serializers.py
Normal 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
3
support/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
19
support/urls.py
Normal file
19
support/urls.py
Normal 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
126
support/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user