From c0cd7ff8f96afc8bfa781b90a2bd7b2671cfc3c6 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Wed, 3 Dec 2025 23:47:34 +0330 Subject: [PATCH] feat(transaction): implement receipt management for transactions - Added `TransactionReceipt` model to store payment receipts uploaded by users. - Introduced endpoints for uploading receipts and listing receipts associated with transactions. - Updated `TransactionParticipant` model to include a new status 'waiting_approval'. - Enhanced serializers to handle receipt uploads and retrievals, including validation for file uploads. - Updated admin interface to manage transaction receipts effectively. - Added comprehensive API documentation for transaction receipt operations. --- .../migrations/0009_alter_book_language.py | 21 + apps/transaction/admin.py | 61 +- apps/transaction/doc.py | 529 ++++++++++++++++++ ..._transactionparticipant_status_and_more.py | 35 ++ apps/transaction/models.py | 44 +- apps/transaction/serializers.py | 57 +- apps/transaction/urls.py | 3 +- apps/transaction/views.py | 165 +++++- templates/docs.html | 79 +++ 9 files changed, 968 insertions(+), 26 deletions(-) create mode 100644 apps/library/migrations/0009_alter_book_language.py create mode 100644 apps/transaction/doc.py create mode 100644 apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py diff --git a/apps/library/migrations/0009_alter_book_language.py b/apps/library/migrations/0009_alter_book_language.py new file mode 100644 index 0000000..e525a47 --- /dev/null +++ b/apps/library/migrations/0009_alter_book_language.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.8 on 2025-12-03 23:32 + +import dj_language.field +import django.db.models.deletion +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dj_language', '0002_auto_20220120_1344'), + ('library', '0008_auto_20251203_1533'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='language', + field=dj_language.field.LanguageField(blank=True, default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='Language'), + ), + ] diff --git a/apps/transaction/admin.py b/apps/transaction/admin.py index ff73c78..35f70a9 100644 --- a/apps/transaction/admin.py +++ b/apps/transaction/admin.py @@ -3,10 +3,10 @@ from django.utils.translation import gettext_lazy as _ from django.utils.html import format_html from django.contrib import messages -from unfold.admin import ModelAdmin, StackedInline +from unfold.admin import ModelAdmin, StackedInline, TabularInline from unfold.decorators import display -from apps.transaction.models import TransactionParticipant, ParticipantInfo +from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt from apps.course.models import Participant from utils.admin import project_admin_site @@ -21,13 +21,25 @@ class ParticipantInfoInline(StackedInline): show_change_link = True +class TransactionReceiptInline(TabularInline): + model = TransactionReceipt + extra = 0 + fields = ['file', 'description', 'uploaded_at'] + readonly_fields = ['uploaded_at'] + classes = ['collapse'] + tab = True + show_change_link = True + verbose_name = _('Payment Receipt') + verbose_name_plural = _('Payment Receipts') + + @admin.register(TransactionParticipant) class TransactionParticipantAdmin(ModelAdmin): - list_display = ('user', 'course', 'payment_status', 'price_display', 'participant_status', 'created_at', 'updated_at') + list_display = ('user', 'course', 'payment_status', 'price_display', 'participant_status', 'receipts_count', 'created_at', 'updated_at') list_filter = ('status', 'course', 'created_at') search_fields = ('user__email', 'course__title') - readonly_fields = [ 'created_at', 'updated_at'] - inlines = [ParticipantInfoInline] + readonly_fields = ['created_at', 'updated_at'] + inlines = [ParticipantInfoInline, TransactionReceiptInline] autocomplete_fields = ['user',] show_change_link = True ordering = ('-created_at',) @@ -48,7 +60,17 @@ class TransactionParticipantAdmin(ModelAdmin): return format_html('Paid') elif obj.status == 'failed': return format_html('Failed') + elif obj.status == 'waiting_approval': + return format_html('Waiting Approval') return format_html('Pending') + + @display(description=_("Receipts Count")) + def receipts_count(self, obj): + """Display count of uploaded receipts""" + count = obj.receipts.count() + if count > 0: + return format_html('{} receipts', count) + return format_html('No receipts') @display(description=_("Price"), ordering="price") def price_display(self, obj): @@ -106,3 +128,32 @@ class TransactionParticipantAdmin(ModelAdmin): return super().get_queryset(request).filter(is_deleted=False) project_admin_site.register(TransactionParticipant, TransactionParticipantAdmin) + + +@admin.register(TransactionReceipt) +class TransactionReceiptAdmin(ModelAdmin): + list_display = ('transaction', 'file', 'uploaded_at', 'description_preview') + list_filter = ('uploaded_at', 'transaction__status') + search_fields = ('transaction__user__email', 'transaction__course__title', 'description') + readonly_fields = ['uploaded_at'] + autocomplete_fields = ['transaction'] + ordering = ('-uploaded_at',) + + fieldsets = ( + (None, { + 'fields': ('transaction', 'file', 'description') + }), + (_('Timestamps'), { + 'fields': ('uploaded_at',), + 'classes': ('collapse',) + }), + ) + + @display(description=_("Description")) + def description_preview(self, obj): + """Display truncated description""" + if obj.description: + return obj.description[:50] + '...' if len(obj.description) > 50 else obj.description + return '-' + +project_admin_site.register(TransactionReceipt, TransactionReceiptAdmin) diff --git a/apps/transaction/doc.py b/apps/transaction/doc.py new file mode 100644 index 0000000..e93f2b2 --- /dev/null +++ b/apps/transaction/doc.py @@ -0,0 +1,529 @@ +def doc_upload_transaction_receipts(): + return """ +# 🐈 Scenario +🛠️ آپلود رسید پرداخت برای تراکنش + +این API برای آپلود یک یا چند رسید پرداخت برای یک تراکنش استفاده می‌شود. +پس از آپلود موفقیت‌آمیز، وضعیت تراکنش به 'waiting_approval' (در انتظار تایید) تغییر می‌کند. + +--- + +## 🚀 روند آپلود (دو مرحله‌ای) + +### مرحله 1️⃣: آپلود فایل به سرور موقت +ابتدا باید فایل‌های خود را به endpoint زیر آپلود کنید: + +``` +POST /upload-tmp-media/ +Content-Type: multipart/form-data + +Body: +- file: [فایل رسید] +``` + +**پاسخ:** +```json +{ + "url": "/static/tmp/xyz123-receipt.jpg", + "name": "receipt.jpg", + "size": "1024000", + "mime_type": "image/jpeg" +} +``` + +### مرحله 2️⃣: ثبت URL فایل‌ها در تراکنش +سپس URL های دریافتی را به این endpoint ارسال کنید: + +``` +POST /api/transactions//receipts/upload/ +Content-Type: application/json +``` + +--- + +## 🚀 درخواست API (مرحله 2) + +### URL: +``` +POST /api/transactions//receipts/upload/ +``` + +### پارامترهای URL: +| کلید | نوع داده | توضیحات | +|------------------|-----------|----------------------------------------------------------| +| `transaction_id` | Integer | شناسه تراکنش که می‌خواهید رسید برای آن ثبت کنید | + +### پارامترهای درخواست (JSON Body): +| کلید | نوع داده | الزامی | توضیحات | +|---------------|-----------|--------|----------------------------------------------------------| +| `files` | String[] | بله | لیست URL های فایل‌های آپلود شده از مرحله 1 (حداکثر 10 فایل) | +| `description` | String | خیر | توضیحات اختیاری درباره رسیدها | + +--- + +## 💡 نکات مهم: +1. **روند دو مرحله‌ای**: + - **مرحله 1**: ابتدا فایل‌ها را به `/upload-tmp-media/` آپلود کنید + - **مرحله 2**: سپس URL های دریافتی را به این API ارسال کنید + +2. **محدودیت فایل‌ها**: + - حداکثر 10 فایل می‌توانید در هر درخواست ثبت کنید + +3. **وضعیت تراکنش**: + - فقط می‌توانید برای تراکنش‌هایی با وضعیت 'pending' یا 'waiting_approval' رسید آپلود کنید + - پس از ثبت موفقیت‌آمیز، وضعیت تراکنش به 'waiting_approval' تغییر می‌کند + +4. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + - فقط می‌توانید برای تراکنش‌های خودتان رسید آپلود کنید + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - رسیدها با موفقیت ثبت شدند | +| `400` | داده‌های نامعتبر یا تراکنش قادر به دریافت رسید نیست | +| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید | +| `404` | تراکنش یافت نشد | + +--- + +## 📄 نمونه درخواست کامل (JSON): + +```json +{ + "files": [ + "/static/tmp/xyz123-receipt1.jpg", + "/static/tmp/abc456-receipt2.jpg" + ], + "description": "Payment receipt for Python course" +} +``` + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "success": true, + "message": "Receipts uploaded successfully", + "transaction_status": "waiting_approval", + "receipts": [ + { + "id": 1, + "file": "http://example.com/media/receipts/1/receipt1.jpg", + "description": "Payment receipt for course enrollment", + "uploaded_at": "2025-12-03T10:30:00Z" + }, + { + "id": 2, + "file": "http://example.com/media/receipts/1/receipt2.jpg", + "description": "Payment receipt for course enrollment", + "uploaded_at": "2025-12-03T10:30:05Z" + } + ] +} +``` + +--- + +## 📄 نمونه درخواست کامل (cURL): + +### مرحله 1 - آپلود فایل: +```bash +curl -X POST \\ + 'http://your-api.com/upload-tmp-media/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ + -F 'file=@/path/to/receipt1.jpg' +``` + +### مرحله 2 - ثبت رسید: +```bash +curl -X POST \\ + 'http://your-api.com/api/transactions/123/receipts/upload/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "files": ["/static/tmp/xyz123-receipt1.jpg"], + "description": "Payment receipt for Python course" + }' +``` + +--- + +## 📄 نمونه پاسخ خطا (403 - عدم دسترسی): + +```json +{ + "message": "You don't have permission to upload receipts for this transaction" +} +``` + +--- + +## 📄 نمونه پاسخ خطا (400 - وضعیت نامعتبر): + +```json +{ + "message": "Cannot upload receipts for transaction with status 'success'" +} +``` +""" + + +def doc_list_transaction_receipts(): + return """ +# 🐈 Scenario +🛠️ لیست رسیدهای پرداخت یک تراکنش + +این API برای دریافت لیست تمام رسیدهای آپلود شده برای یک تراکنش خاص استفاده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +GET /api/transactions//receipts/ +``` + +### پارامترهای URL: +| کلید | نوع داده | توضیحات | +|------------------|-----------|----------------------------------------------------------| +| `transaction_id` | Integer | شناسه تراکنش که می‌خواهید رسیدهای آن را مشاهده کنید | + +--- + +## 💡 نکات مهم: +1. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + - فقط می‌توانید رسیدهای تراکنش‌های خودتان را مشاهده کنید + +2. **مرتب‌سازی**: + - رسیدها بر اساس تاریخ آپلود (جدیدترین اول) مرتب می‌شوند + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیست رسیدها بازگردانده شد | +| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید | +| `404` | تراکنش یافت نشد | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +[ + { + "id": 1, + "file": "http://example.com/media/receipts/1/receipt1.jpg", + "description": "Payment receipt for course enrollment", + "uploaded_at": "2025-12-03T10:30:00Z" + }, + { + "id": 2, + "file": "http://example.com/media/receipts/1/receipt2.jpg", + "description": "Second payment receipt", + "uploaded_at": "2025-12-03T10:25:00Z" + } +] +``` + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|---------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای رسید | +| `file` | String | URL کامل فایل رسید آپلود شده | +| `description` | String | توضیحات اختیاری درباره رسید (ممکن است خالی باشد) | +| `uploaded_at` | DateTime | تاریخ و زمان آپلود رسید | + +--- + +## 📄 نمونه درخواست (cURL): + +```bash +curl -X GET \\ + 'http://your-api.com/api/transactions/123/receipts/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' +``` + +--- + +## 📄 نمونه پاسخ خطا (403 - عدم دسترسی): + +```json +{ + "message": "You don't have permission to view receipts for this transaction" +} +``` + +--- + +## 📄 نمونه پاسخ خطا (404 - تراکنش یافت نشد): + +```json +{ + "message": "Transaction not found" +} +``` +""" + + +def doc_transaction_list(): + return """ +# 🐈 Scenario +🛠️ لیست تراکنش‌های کاربر + +این API برای دریافت لیست تمام تراکنش‌های کاربر احراز هویت شده استفاده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +GET /api/transactions/list/ +``` + +--- + +## 💡 نکات مهم: +1. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + - فقط تراکنش‌های خودتان را مشاهده می‌کنید + +2. **فیلترینگ خودکار**: + - تراکنش‌های حذف شده (soft deleted) نمایش داده نمی‌شوند + +3. **وضعیت‌های تراکنش**: + - `pending`: در انتظار پرداخت + - `waiting_approval`: در انتظار تایید (رسید آپلود شده) + - `success`: پرداخت موفق و تایید شده + - `failed`: پرداخت ناموفق + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیست تراکنش‌ها بازگردانده شد | +| `401` | عدم احراز هویت | + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|---------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای تراکنش | +| `course` | Object | اطلاعات دوره مرتبط با تراکنش | +| `status` | String | وضعیت تراکنش (pending, waiting_approval, success, failed) | +| `price` | Decimal | مبلغ تراکنش | +| `created_at` | DateTime | تاریخ و زمان ایجاد تراکنش | +| `updated_at` | DateTime | تاریخ و زمان آخرین به‌روزرسانی تراکنش | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +[ + { + "id": 1, + "course": { + "id": 5, + "title": "Python Programming Basics", + "slug": "python-programming-basics", + "thumbnail": "http://example.com/media/courses/thumbnails/python.jpg", + "price": "99.00", + "final_price": "79.00" + }, + "status": "waiting_approval", + "price": "79.00", + "created_at": "2025-12-01T10:00:00Z", + "updated_at": "2025-12-03T10:30:00Z" + }, + { + "id": 2, + "course": { + "id": 8, + "title": "Django Web Development", + "slug": "django-web-development", + "thumbnail": "http://example.com/media/courses/thumbnails/django.jpg", + "price": "149.00", + "final_price": "149.00" + }, + "status": "success", + "price": "149.00", + "created_at": "2025-11-28T14:20:00Z", + "updated_at": "2025-11-29T09:15:00Z" + } +] +``` + +--- + +## 📄 نمونه درخواست (cURL): + +```bash +curl -X GET \\ + 'http://your-api.com/api/transactions/list/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' +``` +""" + + +def doc_create_transaction(): + return """ +# 🐈 Scenario +🛠️ ثبت‌نام در دوره و ایجاد تراکنش + +این API برای ثبت‌نام کاربر در یک دوره و ایجاد تراکنش استفاده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/transactions//join/ +``` + +### پارامترهای URL: +| کلید | نوع داده | توضیحات | +|---------|-----------|----------------------------------------------------------| +| `slug` | String | اسلاگ دوره‌ای که می‌خواهید در آن ثبت‌نام کنید | + +### پارامترهای درخواست (JSON Body): +| کلید | نوع داده | الزامی | توضیحات | +|---------------------|-----------|--------|----------------------------------------------------------| +| `participant_infos` | Array | بله | لیست اطلاعات شرکت‌کنندگان | + +### ساختار `participant_infos`: +| کلید | نوع داده | الزامی | توضیحات | +|---------------|-----------|--------|----------------------------------------------------------| +| `fullname` | String | بله | نام کامل شرکت‌کننده | +| `email` | String | بله | ایمیل شرکت‌کننده (برای دوره رایگان باید با ایمیل کاربر احراز هویت شده یکسان باشد) | +| `phone_number`| String | خیر | شماره تلفن شرکت‌کننده | +| `gender` | String | خیر | جنسیت شرکت‌کننده (male, female) | +| `birthdate` | Date | خیر | تاریخ تولد شرکت‌کننده (فرمت: YYYY-MM-DD) | + +--- + +## 💡 نکات مهم: +1. **دوره رایگان**: + - اگر دوره رایگان باشد و فقط یک شرکت‌کننده در لیست باشد و ایمیل او با کاربر احراز هویت شده یکسان باشد، تراکنش به صورت خودکار تایید می‌شود (status = 'success') + - کاربر به صورت خودکار به عنوان دانشجو در دوره ثبت می‌شود + +2. **دوره پولی**: + - تراکنش با وضعیت 'pending' ایجاد می‌شود + - کاربر باید رسید پرداخت خود را آپلود کند + - پس از آپلود رسید، وضعیت به 'waiting_approval' تغییر می‌کند + - پس از تایید توسط ادمین، وضعیت به 'success' تغییر می‌کند + +3. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - تراکنش ایجاد شد | +| `400` | داده‌های نامعتبر | +| `404` | دوره یافت نشد | + +--- + +## 📄 نمونه درخواست (JSON Body): + +```json +{ + "participant_infos": [ + { + "fullname": "علی رضایی", + "email": "ali@example.com", + "phone_number": "+989123456789", + "gender": "male", + "birthdate": "1995-05-15" + } + ] +} +``` + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز (دوره رایگان): + +```json +{ + "message": "Transaction Participant created successfully.", + "transaction_id": 123, + "participant_infos": [ + { + "fullname": "علی رضایی", + "email": "ali@example.com", + "phone_number": "+989123456789", + "gender": "male", + "birthdate": "1995-05-15" + } + ] +} +``` + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز (دوره پولی): + +```json +{ + "message": "Transaction Participant created successfully.", + "transaction_id": 124, + "participant_infos": [ + { + "fullname": "سارا احمدی", + "email": "sara@example.com", + "phone_number": "+989123456789", + "gender": "female", + "birthdate": "1998-03-20" + } + ] +} +``` + +توجه: برای دوره پولی، شما باید با استفاده از `transaction_id` بازگشتی، رسید پرداخت خود را از طریق API آپلود رسید آپلود کنید. + +--- + +## 📄 نمونه درخواست (cURL): + +```bash +curl -X POST \\ + 'http://your-api.com/api/transactions/python-programming-basics/join/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "participant_infos": [ + { + "fullname": "علی رضایی", + "email": "ali@example.com", + "phone_number": "+989123456789", + "gender": "male", + "birthdate": "1995-05-15" + } + ] + }' +``` +""" diff --git a/apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py b/apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py new file mode 100644 index 0000000..a0fd68c --- /dev/null +++ b/apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.8 on 2025-12-03 23:32 + +import apps.transaction.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transaction', '0002_remove_transactionparticipant_is_paid_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='transactionparticipant', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('waiting_approval', 'Waiting for Approval'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20, verbose_name='Transaction Status'), + ), + migrations.CreateModel( + name='TransactionReceipt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(help_text='Upload payment receipt image or document', upload_to=apps.transaction.models.receipt_file_upload_to, verbose_name='Receipt File')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Uploaded At')), + ('description', models.TextField(blank=True, help_text='Optional description or notes about the receipt', null=True, verbose_name='Description')), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', to='transaction.transactionparticipant', verbose_name='Transaction')), + ], + options={ + 'verbose_name': 'Transaction Receipt', + 'verbose_name_plural': 'Transaction Receipts', + 'ordering': ['-uploaded_at'], + }, + ), + ] diff --git a/apps/transaction/models.py b/apps/transaction/models.py index e31d002..a017719 100644 --- a/apps/transaction/models.py +++ b/apps/transaction/models.py @@ -1,4 +1,5 @@ from django.db import models +import os from django.utils.translation import gettext_lazy as _ @@ -8,6 +9,10 @@ from phonenumber_field.modelfields import PhoneNumberField from utils.validators import validate_possible_number +def receipt_file_upload_to(instance, filename): + return os.path.join(f"receipts/{instance.transaction.id}/{filename}") + + @@ -16,6 +21,7 @@ class TransactionParticipant(models.Model): class TransactionStatus(models.TextChoices): PENDING = 'pending', _('Pending') + WAITING_APPROVAL = 'waiting_approval', _('Waiting for Approval') SUCCESS = 'success', _('Success') FAILED = 'failed', _('Failed') @@ -56,9 +62,9 @@ class ParticipantInfo(models.Model): FEMALE = 'female', 'Female' transaction_participant = models.ForeignKey( - TransactionParticipant, - on_delete=models.CASCADE, - related_name='participant_infos', + TransactionParticipant, + on_delete=models.CASCADE, + related_name='participant_infos', verbose_name="Transaction Participant" ) fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.") @@ -73,6 +79,38 @@ class ParticipantInfo(models.Model): return f"{self.fullname} (Transaction: {self.transaction_participant.id}) - {self.email}" +class TransactionReceipt(models.Model): + """ + Model for storing payment receipts uploaded by users for transactions + """ + transaction = models.ForeignKey( + TransactionParticipant, + on_delete=models.CASCADE, + related_name='receipts', + verbose_name=_('Transaction') + ) + file = models.FileField( + upload_to=receipt_file_upload_to, + verbose_name=_('Receipt File'), + help_text=_('Upload payment receipt image or document') + ) + uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Uploaded At')) + description = models.TextField( + blank=True, + null=True, + verbose_name=_('Description'), + help_text=_('Optional description or notes about the receipt') + ) + + class Meta: + verbose_name = _('Transaction Receipt') + verbose_name_plural = _('Transaction Receipts') + ordering = ['-uploaded_at'] + + def __str__(self): + return f"Receipt for Transaction #{self.transaction.id} - {self.uploaded_at.strftime('%Y-%m-%d %H:%M')}" + + diff --git a/apps/transaction/serializers.py b/apps/transaction/serializers.py index c7163c0..f3c91f7 100644 --- a/apps/transaction/serializers.py +++ b/apps/transaction/serializers.py @@ -1,8 +1,9 @@ from rest_framework import serializers -from apps.transaction.models import TransactionParticipant, ParticipantInfo +from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt from apps.course.serializers import CourseDetailSerializer +from utils import FileFieldSerializer @@ -39,11 +40,57 @@ class TransactionParticipantSerializer(serializers.ModelSerializer): class TransactionListSerializer(serializers.ModelSerializer): course = serializers.SerializerMethodField() - + receipts = serializers.SerializerMethodField() + class Meta: model = TransactionParticipant - fields = ['id', 'course', 'status', 'price', 'created_at', 'updated_at'] - + fields = ['id', 'course', 'status', 'price', 'receipts', 'created_at', 'updated_at'] + def get_course(self, obj): return CourseDetailSerializer(obj.course, context=self.context).data - \ No newline at end of file + + def get_receipts(self, obj): + receipts = obj.receipts.all() + return TransactionReceiptSerializer(receipts, many=True, context=self.context).data + + +class TransactionReceiptSerializer(serializers.ModelSerializer): + """ + Serializer for uploading payment receipts + Uses FileFieldSerializer to handle pre-uploaded files from /upload-tmp-media/ + """ + file = FileFieldSerializer() + + class Meta: + model = TransactionReceipt + fields = ['id', 'file', 'description', 'uploaded_at'] + read_only_fields = ['id', 'uploaded_at'] + + +class UploadReceiptsSerializer(serializers.Serializer): + """ + Serializer for uploading multiple receipt files for a transaction. + Files should be pre-uploaded using /upload-tmp-media/ endpoint, + then their URLs should be sent here. + """ + files = serializers.ListField( + child=FileFieldSerializer(), + allow_empty=False, + max_length=10, + help_text="List of file URLs (max 10 files) - files should be pre-uploaded via /upload-tmp-media/" + ) + description = serializers.CharField( + required=False, + allow_blank=True, + max_length=1000, + help_text="Optional description for the receipts" + ) + + def validate_files(self, files): + """ + Validate uploaded file URLs + """ + if len(files) > 10: + raise serializers.ValidationError("You can upload a maximum of 10 files.") + + return files \ No newline at end of file diff --git a/apps/transaction/urls.py b/apps/transaction/urls.py index a6d1022..e0bae3d 100644 --- a/apps/transaction/urls.py +++ b/apps/transaction/urls.py @@ -9,7 +9,8 @@ urlpatterns = [ path('/join/', views.TransactionParticipantCreateView.as_view(), name='transaction-participant-create'), path('list/', views.TransactiontListView.as_view(), name='transaction-list'), path('/delete/', views.SoftDeleteTransactionParticipantView.as_view(), name='soft-delete-transaction-participant'), - + path('/receipts/upload/', views.UploadTransactionReceiptsView.as_view(), name='upload-transaction-receipts'), + path('/receipts/', views.TransactionReceiptsListView.as_view(), name='transaction-receipts-list'), ] \ No newline at end of file diff --git a/apps/transaction/views.py b/apps/transaction/views.py index 99274a4..7232d9e 100644 --- a/apps/transaction/views.py +++ b/apps/transaction/views.py @@ -3,12 +3,23 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.course.models import Participant, Course -from apps.transaction.models import TransactionParticipant -from apps.transaction.serializers import TransactionParticipantSerializer, TransactionListSerializer +from apps.transaction.models import TransactionParticipant, TransactionReceipt +from apps.transaction.serializers import ( + TransactionParticipantSerializer, + TransactionListSerializer, + UploadReceiptsSerializer, + TransactionReceiptSerializer +) from utils.exceptions import AppAPIException from apps.account.models import User from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi +from apps.transaction.doc import ( + doc_upload_transaction_receipts, + doc_list_transaction_receipts, + doc_transaction_list, + doc_create_transaction +) @@ -17,7 +28,9 @@ class TransactionParticipantCreateView(generics.CreateAPIView): serializer_class = TransactionParticipantSerializer permission_classes = [IsAuthenticated] - + @swagger_auto_schema( + operation_description=doc_create_transaction() + ) def create(self, request, *args, **kwargs): user = request.user course_slug = self.kwargs.get('slug') # Get the slug from the URL @@ -65,11 +78,13 @@ class TransactionParticipantCreateView(generics.CreateAPIView): class TransactiontListView(generics.ListAPIView): - queryset = TransactionParticipant.objects.all() # یا هر فیلتر که بخواهید اضافه کنید + queryset = TransactionParticipant.objects.all() serializer_class = TransactionListSerializer - permission_classes = [IsAuthenticated] # برای دسترسی کاربران احراز هویت شده - + permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_description=doc_transaction_list() + ) def get_queryset(self): queryset = super().get_queryset() queryset = queryset.filter(user=self.request.user, is_deleted=False) @@ -79,16 +94,16 @@ class TransactiontListView(generics.ListAPIView): class SoftDeleteTransactionParticipantView(APIView): permission_classes = [IsAuthenticated] - + @swagger_auto_schema( operation_summary="Soft delete a transaction participant", operation_description="Marks a transaction participant as deleted without removing it from the database", manual_parameters=[ openapi.Parameter( - 'id', - openapi.IN_PATH, - description="Transaction Participant ID", - type=openapi.TYPE_INTEGER, + 'id', + openapi.IN_PATH, + description="Transaction Participant ID", + type=openapi.TYPE_INTEGER, required=True ) ], @@ -121,6 +136,132 @@ class SoftDeleteTransactionParticipantView(APIView): detail={'message': "You don't have permission to delete this transaction"}, status_code=status.HTTP_403_FORBIDDEN ) - + except TransactionParticipant.DoesNotExist: raise AppAPIException({'message': "Transaction participant not found"}) + + +class UploadTransactionReceiptsView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="Upload payment receipts for a transaction", + operation_description=doc_upload_transaction_receipts(), + request_body=UploadReceiptsSerializer, + responses={ + 201: openapi.Response( + description="Receipts uploaded successfully", + examples={ + "application/json": { + "success": True, + "message": "Receipts uploaded successfully", + "transaction_status": "waiting_approval", + "receipts": [ + { + "id": 1, + "file": "http://example.com/media/receipts/1/receipt.jpg", + "description": "Payment receipt", + "uploaded_at": "2025-12-03T10:30:00Z" + } + ] + } + } + ), + 400: "Invalid data or transaction cannot accept receipts", + 403: "Permission denied", + 404: "Transaction not found" + } + ) + def post(self, request, transaction_id): + try: + transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False) + except TransactionParticipant.DoesNotExist: + raise AppAPIException({'message': "Transaction not found"}) + + # Check if user owns this transaction + if transaction.user != request.user: + raise AppAPIException( + detail={'message': "You don't have permission to upload receipts for this transaction"}, + status_code=status.HTTP_403_FORBIDDEN + ) + + # Check if transaction is in a state that can accept receipts + if transaction.status not in [ + TransactionParticipant.TransactionStatus.PENDING, + TransactionParticipant.TransactionStatus.WAITING_APPROVAL + ]: + raise AppAPIException( + detail={'message': f"Cannot upload receipts for transaction with status '{transaction.status}'"}, + status_code=status.HTTP_400_BAD_REQUEST + ) + + # Validate using serializer + serializer = UploadReceiptsSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # Create receipt records + receipts = [] + description = serializer.validated_data.get('description', '') + file_urls = serializer.validated_data.get('files', []) + + for file_url in file_urls: + receipt = TransactionReceipt.objects.create( + transaction=transaction, + file=file_url, + description=description + ) + receipts.append(receipt) + + # Update transaction status to waiting_approval + transaction.status = TransactionParticipant.TransactionStatus.WAITING_APPROVAL + transaction.save() + + # Serialize receipts for response + receipts_data = TransactionReceiptSerializer(receipts, many=True, context={'request': request}).data + + return Response({ + 'success': True, + 'message': 'Receipts uploaded successfully', + 'transaction_status': transaction.status, + 'receipts': receipts_data + }, status=status.HTTP_201_CREATED) + + +class TransactionReceiptsListView(generics.ListAPIView): + serializer_class = TransactionReceiptSerializer + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="List receipts for a transaction", + operation_description=doc_list_transaction_receipts(), + manual_parameters=[ + openapi.Parameter( + 'transaction_id', + openapi.IN_PATH, + description="Transaction ID", + type=openapi.TYPE_INTEGER, + required=True + ) + ], + responses={ + 200: TransactionReceiptSerializer(many=True), + 403: "Permission denied", + 404: "Transaction not found" + } + ) + def get_queryset(self): + transaction_id = self.kwargs.get('transaction_id') + + try: + transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False) + except TransactionParticipant.DoesNotExist: + raise AppAPIException({'message': "Transaction not found"}) + + # Check if user owns this transaction + if transaction.user != self.request.user: + raise AppAPIException( + detail={'message': "You don't have permission to view receipts for this transaction"}, + status_code=status.HTTP_403_FORBIDDEN + ) + + return TransactionReceipt.objects.filter(transaction=transaction) diff --git a/templates/docs.html b/templates/docs.html index c5f9142..f1dc6f5 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -64,6 +64,85 @@ + +
+
+
+ مستندات API تراکنش‌ها و رسیدهای پرداخت +
+
+

🔹 ثبت‌نام در دوره و ایجاد تراکنش

+

Endpoint: POST /api/transactions/<slug>/join/

+

این API برای ثبت‌نام کاربر در دوره و ایجاد تراکنش استفاده می‌شود.

+
    +
  • برای دوره‌های رایگان، تراکنش به صورت خودکار تایید می‌شود
  • +
  • برای دوره‌های پولی، تراکنش با وضعیت pending ایجاد می‌شود
  • +
+
+ +

🔹 آپلود رسید پرداخت

+

Endpoint: POST /api/transactions/<transaction_id>/receipts/upload/

+

برای آپلود رسید پرداخت دوره‌های پولی استفاده می‌شود.

+
    +
  • حداکثر 10 فایل قابل آپلود در هر درخواست
  • +
  • حداکثر حجم هر فایل: 10 مگابایت
  • +
  • پس از آپلود موفق، وضعیت تراکنش به waiting_approval تغییر می‌کند
  • +
+
+ +

🔹 مشاهده رسیدهای یک تراکنش

+

Endpoint: GET /api/transactions/<transaction_id>/receipts/

+

برای دریافت لیست تمام رسیدهای آپلود شده برای یک تراکنش.

+
+ +

🔹 لیست تراکنش‌های کاربر

+

Endpoint: GET /api/transactions/list/

+

برای دریافت لیست تمام تراکنش‌های کاربر احراز هویت شده.

+
+ +

🔹 وضعیت‌های تراکنش

+ + + + + + + + + + + + + + + + + + + + + + + + + +
وضعیتتوضیحات
pendingدر انتظار پرداخت - کاربر باید رسید را آپلود کند
waiting_approvalدر انتظار تایید - رسید آپلود شده و منتظر تایید ادمین
successپرداخت موفق و تایید شده - کاربر به دوره دسترسی دارد
failedپرداخت ناموفق یا رد شده
+
+ +

📌 نکات مهم برای ادمین

+
    +
  • زمانی که کاربر رسید آپلود می‌کند، وضعیت تراکنش به waiting_approval تغییر می‌کند
  • +
  • ادمین باید رسیدها را در پنل ادمین بررسی کرده و وضعیت را به success یا failed تغییر دهد
  • +
  • زمانی که وضعیت به success تغییر کند، کاربر به صورت خودکار به عنوان دانشجو در دوره ثبت می‌شود
  • +
  • تمام رسیدهای آپلود شده در پنل ادمین قابل مشاهده هستند
  • +
+ +

+ مشاهده مستندات کامل Swagger +

+
+
+
{% endblock %} \ No newline at end of file