From 738aa6aa8a7adcf1a0fbc35a61100fdd7c618af9 Mon Sep 17 00:00:00 2001 From: alireza Date: Fri, 22 Nov 2024 19:17:18 +0330 Subject: [PATCH] init project add models --- apps/account/admin/professor.py | 26 +- apps/account/admin/student.py | 40 +- apps/account/doc.py | 835 ++++++++++++++++++ apps/account/migrations/0004_user_skill.py | 18 + apps/account/models/user.py | 1 + apps/account/serializers/user.py | 24 +- apps/account/views/user.py | 35 +- apps/chat/__init__.py | 0 apps/chat/admin.py | 3 + apps/chat/apps.py | 6 + apps/chat/migrations/0001_initial.py | 35 + apps/chat/migrations/__init__.py | 0 apps/chat/models.py | 60 ++ apps/{account => chat}/tests.py | 0 apps/chat/views.py | 3 + apps/course/admin/__init__.py | 3 +- apps/course/admin/course.py | 31 +- apps/course/admin/lesson.py | 21 +- apps/course/admin/participant.py | 33 + apps/course/doc.py | 410 +++++++++ apps/course/migrations/0001_initial.py | 115 +++ .../migrations/0002_auto_20241121_2238.py | 32 + apps/course/migrations/0003_participant.py | 27 + .../migrations/0004_auto_20241122_1913.py | 42 + apps/course/models/__init__.py | 3 +- apps/course/models/course.py | 17 +- apps/course/models/lesson.py | 33 + apps/course/models/participant.py | 24 + apps/course/serializers/__init__.py | 3 +- apps/course/serializers/course.py | 113 ++- apps/course/serializers/lesson.py | 39 + apps/course/urls.py | 4 + apps/course/views/__init__.py | 4 +- apps/course/views/course.py | 106 ++- apps/course/views/lesson.py | 28 + apps/course/views/participant.py | 27 + config/settings/base.py | 4 +- config/test_auth_middleware.py | 4 + templates/admin/auth/user/add_form.html | 10 + .../admin/auth/user/change_password.html | 57 ++ utils/exceptions.py | 124 +++ utils/schema.py | 52 +- 42 files changed, 2383 insertions(+), 69 deletions(-) create mode 100644 apps/account/migrations/0004_user_skill.py create mode 100644 apps/chat/__init__.py create mode 100644 apps/chat/admin.py create mode 100644 apps/chat/apps.py create mode 100644 apps/chat/migrations/0001_initial.py create mode 100644 apps/chat/migrations/__init__.py create mode 100644 apps/chat/models.py rename apps/{account => chat}/tests.py (100%) create mode 100644 apps/chat/views.py create mode 100644 apps/course/admin/participant.py create mode 100644 apps/course/doc.py create mode 100644 apps/course/migrations/0001_initial.py create mode 100644 apps/course/migrations/0002_auto_20241121_2238.py create mode 100644 apps/course/migrations/0003_participant.py create mode 100644 apps/course/migrations/0004_auto_20241122_1913.py create mode 100644 apps/course/models/participant.py create mode 100644 apps/course/serializers/lesson.py create mode 100644 apps/course/views/lesson.py create mode 100644 apps/course/views/participant.py create mode 100644 templates/admin/auth/user/add_form.html create mode 100644 templates/admin/auth/user/change_password.html diff --git a/apps/account/admin/professor.py b/apps/account/admin/professor.py index 583293e..0c1142e 100644 --- a/apps/account/admin/professor.py +++ b/apps/account/admin/professor.py @@ -28,16 +28,25 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable): add_fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('email', 'password1', 'password2'), + 'fields': ('fullname', 'email', 'phone_number',), }), + ('other', { + 'classes': ('wide',), + 'fields': ('avatar', 'info', 'skill'), + }), + ('Password', { + 'classes': ('wide',), + 'fields': ('password1', 'password2'), + }), + ) search_fields = ( - 'email', 'fullname', 'username', + 'email', 'fullname', ) fieldsets = ( - (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar', 'info', 'skill')}), (_('Permissions'), { - 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + 'fields': ('is_active', 'groups', 'password'), }), (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), ) @@ -53,4 +62,11 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable): return obj.phone_number -# admin.site.unregister(TokenProxy) + def get_readonly_fields(self, request, obj=None): + """ + Restrict the ability to modify groups to superusers only. + """ + readonly = list(self.readonly_fields) + if not request.user.is_superuser: + readonly.append('groups') + return readonly \ No newline at end of file diff --git a/apps/account/admin/student.py b/apps/account/admin/student.py index 2b1cf11..09d72d1 100644 --- a/apps/account/admin/student.py +++ b/apps/account/admin/student.py @@ -13,7 +13,7 @@ from django.urls import path, reverse from django.shortcuts import render, redirect from django.contrib import messages -from apps.account.models import StudentUser +from apps.account.models import StudentUser, User @@ -28,7 +28,17 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable): add_fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('email', 'password1', 'password2'), + 'fields': ('fullname', 'email', 'phone_number',), + # 'description': 'Please provide the student details including full name, email, and phone number.', + + }), + ('other', { + 'classes': ('wide',), + 'fields': ('avatar', 'info'), + }), + ('Password', { + 'classes': ('wide',), + 'fields': ('password1', 'password2'), }), ) search_fields = ( @@ -37,20 +47,32 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable): fieldsets = ( (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), (_('Permissions'), { - 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups',), }), (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), ) + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + + def get_queryset(self, request): + # محدود کردن نمایش فقط دانش‌آموزان + qs = super().get_queryset(request) + return qs.filter(user_type=User.UserType.STUDENT) + def save_model(self, request, obj, form, change): if not change: obj.set_password(form.cleaned_data['password1']) - obj.user_type = User.UserType.PROFESSOR + obj.user_type = User.UserType.STUDENT super().save_model(request, obj, form, change) - @admin.display(description='Phone Number') - def _phone_number(self, obj): - return obj.phone_number - -# admin.site.unregister(TokenProxy) + def has_add_permission(self, request): + if '_popup' in request.GET and request.GET['_popup'] == '1': # بررسی وجود _popup در پارامترهای GET + return True + return False + + def has_delete_permission(self, request, obj=None): + return False \ No newline at end of file diff --git a/apps/account/doc.py b/apps/account/doc.py index e69de29..be654ad 100644 --- a/apps/account/doc.py +++ b/apps/account/doc.py @@ -0,0 +1,835 @@ +def doc_reset(): + return """ +# 🐈 Scenario +🛠️ تنظیم مجدد رمز عبور + +کاربر پس از تأیید کد بازیابی رمز عبور، می‌تواند رمز عبور جدید خود را تنظیم کند. برای این کار، کاربر باید رمز عبور جدید و تأیید آن را وارد کند. + +بعد از ریکاور و وریفای +به این صفحه برای ریست میآید +که باید با همان توکنی که در وریفای دریافت کرده است را درخواست کند + +(نکته بعد از ریست پسورد توکن ذخیره شده حذف شود و کاربر باید با رمز عبور جدیدی که ست کرده است مجددا لاگین را انجام دهد) +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/reset-password/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Bearer <توکن احراز هویت> | + +### Body: +```json +{ + "password": "newstrongpassword", + "password_confirmation": "newstrongpassword" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - رمز عبور با موفقیت تغییر یافت. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `401` | عدم احراز هویت - کاربر وارد نشده است یا توکن نامعتبر است. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "message": "Your password has been changed successfully." +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### رمز عبور و تأیید رمز عبور برابر نیستند: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "Passwords do not match." +} +``` + +### رمز عبور کوتاه‌تر از 8 کاراکتر است: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "Password must be at least 8 characters long." +} +``` + +### عدم احراز هویت: +```json +{ + "status": "error", + "code": "unauthorized", + "status_code": 401, + "message": "Authentication credentials were not provided or are invalid." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **رمز عبور جدید:** + - باید حداقل 8 کاراکتر باشد و تأیید رمز عبور (`password_confirmation`) باید با رمز عبور اصلی یکسان باشد. +2. **امنیت:** + - کاربر باید توکن احراز هویت معتبر برای تنظیم مجدد رمز عبور ارائه دهد. +3. **توکن احراز هویت:** + - فقط کاربران احراز هویت شده می‌توانند رمز عبور خود را تغییر دهند. + +--- + +## 🔧 توضیحات فنی: + +### فرآیند تنظیم مجدد رمز عبور: +1. کاربر باید ابتدا کد بازیابی رمز عبور را تأیید کند. +2. پس از تأیید موفقیت‌آمیز، کاربر با استفاده از توکن احراز هویت، رمز عبور جدید و تأیید آن را وارد می‌کند. +3. اگر داده‌ها معتبر باشند، رمز عبور جدید برای کاربر تنظیم می‌شود. +4. اگر داده‌ها نادرست باشند، پیام خطای مناسب به کاربر بازگردانده می‌شود. + +### ولیدیشن‌ها: +- **رمز عبور:** + - بررسی می‌شود که رمز عبور حداقل 8 کاراکتر باشد. + - بررسی می‌شود که رمز عبور و تأیید آن یکسان باشند. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "password": "mynewpassword", + "password_confirmation": "mynewpassword" +} +``` + +### پاسخ موفق: +```json +{ + "message": "Your password has been changed successfully." +} +``` +""" + + + +def doc_recover(): + return """ +# 🐈 Scenario +🛠️ بازیابی رمز عبور + +کاربر با وارد کردن ایمیل خود، درخواست بازیابی رمز عبور می‌دهد. +یک کد تأیید به ایمیل کاربر ارسال می‌شود تا کاربر بتواند رمز عبور خود را بازیابی کند. +سپس کاربر باید به صفحه وریفای ریدایرکت شود +و بعد از تایید وریفای با توکن داده شده +به صفحه ریست پسورد ریدایرکت میشود تا پسور جدیدی را ست کند + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/recover-password/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "email": "johndoe@example.com" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `202` | موفقیت‌آمیز - کد بازیابی رمز عبور به ایمیل کاربر ارسال شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `404` | کاربر یافت نشد. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "id": 1, + "fullname": "John Doe", + "phone_number": "1234567890", + "email": "johndoe@example.com", + "avatar": null, + "message": "Forgot password code sent" +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### کاربر یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "User not found." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **کد بازیابی رمز عبور:** + - کد تأیید به ایمیل کاربر ارسال می‌شود و باید در مرحله بعدی برای بازیابی رمز عبور استفاده شود. +2. **امنیت:** + - کد بازیابی رمز عبور فقط برای مدت محدود اعتبار دارد و بعد از آن منقضی می‌شود. + +--- + +## 🔧 توضیحات فنی: + +### فرآیند بازیابی رمز عبور: +1. کاربر ایمیل خود را وارد می‌کند. +2. سیستم بررسی می‌کند که آیا کاربری با این ایمیل وجود دارد یا خیر. +3. اگر کاربر یافت شود، یک کد تأیید بازیابی رمز عبور به ایمیل کاربر ارسال می‌شود. +4. کاربر باید این کد را در مرحله بعدی برای تنظیم رمز عبور جدید وارد کند. + +### ولیدیشن‌ها: +- **ایمیل:** + - بررسی می‌شود که ایمیل وارد شده معتبر باشد. + - اگر کاربری با این ایمیل یافت نشود، پیام خطای مناسب برگردانده می‌شود. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "email": "janedoe@example.com" +} +``` + +### پاسخ موفق: +```json +{ + "id": 2, + "fullname": "Jane Doe", + "phone_number": "0987654321", + "email": "janedoe@example.com", + "avatar": null, + "message": "Forgot password code sent" +} +``` +""" + + + +def doc_login(): + return """ +# 🐈 Scenario +🛠️ ورود به حساب کاربری + +کاربر با وارد کردن ایمیل و رمز عبور خود به سیستم وارد می‌شود. اگر اعتبارنامه‌ها معتبر باشند، توکن احراز هویت برای دسترسی به دیگر بخش‌های سیستم بازگردانده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/login/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "email": "johndoe@example.com", + "password": "strongpassword", + "fcm": "fcm_token_optional", + "device_id": "device_id_optional" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - کاربر با موفقیت وارد شد و توکن احراز هویت بازگردانده شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `404` | کاربر یافت نشد. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "id": 1, + "fullname": "John Doe", + "email": "johndoe@example.com", + "token": "abc123def456", + "avatar": "https://example.com/avatar.jpg" +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### ورود ناموفق (اطلاعات اشتباه): +```json +{ + "status": "error", + "code": "invalid_credentials", + "status_code": 400, + "message": "Unable to log in with provided credentials." +} +``` + +### کاربر یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "User not found." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **رمز عبور:** + - رمز عبور باید صحیح و مطابق با آنچه کاربر هنگام ثبت‌نام ارائه کرده است، باشد. +2. **توکن احراز هویت:** + - پس از ورود موفقیت‌آمیز، توکن احراز هویت به کاربر بازگردانده می‌شود که برای دسترسی به دیگر بخش‌های سیستم نیاز است. +3. **اطلاعات دستگاه:** + - `fcm` و `device_id` به عنوان اطلاعات اختیاری برای شناسایی دستگاه ارسال می‌شوند. + +--- + +## 🔧 توضیحات فنی: + +### فرآیند ورود به حساب کاربری: +1. کاربر ایمیل و رمز عبور خود را وارد می‌کند. +2. سیستم سعی می‌کند کاربر را با استفاده از اعتبارنامه‌های ارائه شده احراز هویت کند. +3. اگر کاربر یافت شود و اعتبارنامه‌ها صحیح باشند، یک توکن احراز هویت ایجاد شده و به کاربر بازگردانده می‌شود. +4. اگر اعتبارنامه نادرست باشند، پیام خطا برگردانده می‌شود. + +### ولیدیشن‌ها: +- **ایمیل و رمز عبور:** + - بررسی می‌شود که ایمیل و رمز عبور وارد شده معتبر باشند. + - اگر کاربر با این ایمیل و رمز عبور یافت نشود، پیام خطای مناسب برگردانده می‌شود. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "email": "janedoe@example.com", + "password": "mypassword", + "fcm": "fcm_token_example", + "device_id": "device_id_example" +} +``` + +### پاسخ موفق: +```json +{ + "id": 2, + "fullname": "Jane Doe", + "email": "janedoe@example.com", + "token": "xyz987uvw654", + "avatar": null +} +``` +""" + + +def doc_verify(): + return """ +# 🐈 Scenario +📅️ تأیید حساب کاربری با کد تأیید + +کاربر پس از ثبت‌نام، باید با استفاده از کد تأییدی که به ایمیل او ارسال شده است، +حساب کاربری خود را تأیید کند. در این مرحله، کاربر ایمیل و کد تأیید خود را ارسال می‌کند. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/verify/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "email": "johndoe@example.com", + "code": "12345" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - کاربر تأیید شد و توکن احراز هویت بازگردانده شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `404` | کاربر یا کد تأیید یافت نشد. | +| `410` | کد تأیید منقضی شده است. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "token": "abc123def456", + "user_id": 1, + "phone_number": "1234567890", + "email": "johndoe@example.com", + "fullname": "John Doe", + "avatar": null +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### کد تأیید نادرست: +```json +{ + "status": "error", + "code": "invalid_verification_code", + "status_code": 400, + "message": "The verification code is invalid." +} +``` + +### کد تأیید منقضی شده است: +```json +{ + "status": "error", + "code": "expired_code", + "status_code": 410, + "message": "The verification code has expired." +} +``` + +### کاربر یا کد تأیید یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "Verification data not found or expired." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **کد تأیید:** + - کد تأیید باید دقیقاً با کدی که به ایمیل کاربر ارسال شده مطابقت داشته باشد. + - کد تأیید فقط برای یک مدت محدود اعتبار دارد. +2. **خطاها:** + - اگر کد تأیید نادرست باشد، پیام مناسب بازگردانده می‌شود. + - اگر کد تأیید منقضی شده باشد، کاربر باید درخواست کد جدید کند. +3. **توکن احراز هویت:** + - پس از تأیید موفقیت‌آمیز، توکن احراز هویت به کاربر بازگردانده می‌شود که برای دسترسی به دیگر بخش‌های سیستم نیاز است. + +--- + +### ولیدیشن‌ها: +- **کد تأیید:** + - باید حداکثر 5 کاراکتر باشد. + - اگر کد معتبر نباشد یا منقضی شده باشد، پیام خطای مناسب برگردانده می‌شود. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "email": "janedoe@example.com", + "code": "67890" +} +``` + +### پاسخ موفق: +```json +{ + "token": "xyz987uvw654", + "user_id": 2, + "phone_number": "0987654321", + "email": "janedoe@example.com", + "fullname": "Jane Doe", + "avatar": null +} +``` +""" + + +def doc_register(): + return """ +# 🐈 Scenario +ثبت نام کاربر + +کاربر با وارد کردن اطلاعات مورد نیاز شامل نام کامل، ایمیل، رمز عبور و تأیید رمز عبور درخواست ثبت‌نام ارسال می‌کند. پس از ثبت موفق، یک کد تأیید به ایمیل ارسال می‌شود که برای تکمیل ثبت‌نام مورد نیاز است. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/register/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "fullname": "John Doe", + "email": "johndoe@example.com", + "password": "strongpassword", + "password_confirmation": "strongpassword", + "fcm": "fcm_token_optional", + "device_id": "device_id_optional" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `202` | موفقیت‌آمیز - کد تأیید به ایمیل کاربر ارسال شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `409` | ایمیل قبلاً ثبت شده است. | +| `404` | کاربر یا منبع یافت نشد. | +| `410` | کد تأیید منقضی شده است. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "user": { + "id": 1, + "fullname": "John Doe", + "email": "johndoe@example.com" + }, + "message": "The otp code was sent to the user's email" +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### ایمیل تکراری: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 409, + "message": "There were validation errors.", + "errors": [ + { + "field": "email", + "message": "This email is already registered." + } + ] +} +``` + +### رمز عبور و تأیید رمز عبور برابر نیستند: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password_confirmation", + "message": "Passwords do not match." + } + ] +} +``` + +### رمز عبور کوتاه‌تر از 8 کاراکتر است: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password", + "message": "Password must be at least 8 characters long." + } + ] +} +``` + +### درخواست نامعتبر (فیلدهای اجباری): +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "fullname", + "message": "This field is required." + }, + { + "field": "email", + "message": "This field is required." + }, + { + "field": "password", + "message": "This field is required." + }, + { + "field": "password_confirmation", + "message": "This field is required." + } + ] +} +``` + +### کاربر یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "The requested resource was not found." +} +``` + +### کد تأیید منقضی شده است: +```json +{ + "status": "error", + "code": "expired_code", + "status_code": 410, + "message": "The verification code has expired." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **رمز عبور:** + - باید حداقل 8 کاراکتر باشد. + - رمز عبور و تأیید رمز عبور (`password_confirmation`) باید یکسان باشند. +2. **ایمیل:** + - باید یک آدرس ایمیل معتبر باشد. + - ایمیل‌های تکراری مجاز نیستند. +3. **کد OTP:** + - کد تأیید به ایمیل ارسال می‌شود و برای وریفای کاربر استفاده می‌شود. +4. **فیلدهای اختیاری:** + - `fcm` و `device_id` در صورت نیاز می‌توانند ارسال شوند اما اجباری نیستند. + +--- + +### ولیدیشن‌ها: +- **ایمیل:** + - بررسی می‌شود که در سیستم موجود نباشد. + - اگر موجود باشد، پیام خطای زیر برگردانده می‌شود: + ```json + { + "status": "error", + "code": "validation_error", + "status_code": 409, + "message": "There were validation errors.", + "errors": [ + { + "field": "email", + "message": "This email is already registered." + } + ] + } + ``` +- **رمز عبور:** + - بررسی می‌شود که حداقل 8 کاراکتر باشد: + ```json + { + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password", + "message": "Password must be at least 8 characters long." + } + ] + } + ``` + - بررسی می‌شود که با `password_confirmation` یکسان باشد: + ```json + { + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password_confirmation", + "message": "Passwords do not match." + } + ] + } + ``` + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "fullname": "Jane Doe", + "email": "janedoe@example.com", + "password": "securepassword", + "password_confirmation": "securepassword", + "fcm": "fcm_token_example", + "device_id": "device_id_example" +} +``` + +### پاسخ موفق: +```json +{ + "user": { + "id": 2, + "fullname": "Jane Doe", + "email": "janedoe@example.com" + }, + "message": "The otp code was sent to the user's email" +} +``` +""" diff --git a/apps/account/migrations/0004_user_skill.py b/apps/account/migrations/0004_user_skill.py new file mode 100644 index 0000000..0b1ade8 --- /dev/null +++ b/apps/account/migrations/0004_user_skill.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2024-11-22 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_auto_20241120_1741'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='skill', + field=models.CharField(blank=True, max_length=512, null=True), + ), + ] diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 0015041..acee270 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -49,6 +49,7 @@ class User(AbstractUser): is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.") deleted_at = models.DateTimeField(null=True, blank=True) info = models.TextField(verbose_name="Info", null=True, blank=True) + skill = models.CharField(max_length=512, null=True, blank=True) objects = UserManager() diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index 34aaf63..ff2d04d 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -15,7 +15,7 @@ class UserProfileSerializer(serializers.ModelSerializer): fullname = serializers.CharField(required=False) class Meta: model = User - fields = ['id', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info'] + fields = ['id', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill'] read_only_fields = ['email', 'info'] # def validate_email(self, value): @@ -54,24 +54,26 @@ class UserRegisterSerializer(serializers.ModelSerializer): def validate_email(self, value): if User.objects.filter(email=value).exists(): - raise serializers.ValidationError("This email is already registered.") + raise serializers.ValidationError({"email": "This email is already registered."}) return value def validate(self, data): password = data.get('password') password_confirmation = data.get('password_confirmation') + errors = {} + if password and password_confirmation and password != password_confirmation: - raise serializers.ValidationError("Passwords do not match.") + raise serializers.ValidationError({"password_confirmation": "Passwords do not match."}) if len(password) < 8: - raise serializers.ValidationError("Password must be at least 8 characters long.") + raise serializers.ValidationError({"password": "Password must be at least 8 characters long."}) + # If there are any errors, raise ValidationError data.pop('password_confirmation', None) data.pop('fcm', None) data.pop('device_id', None) return data - class UserVerifySerializer(serializers.ModelSerializer): code = serializers.CharField(max_length=5, validators=[validate_type_code]) @@ -136,17 +138,19 @@ class UserResetPasswordSerializer(serializers.ModelSerializer): 'password_confirmation': {'required': True,}, } - + def validate(self, data): password = data.get('password') password_confirmation = data.get('password_confirmation') + errors = {} + if password and password_confirmation and password != password_confirmation: - raise serializers.ValidationError("Passwords do not match.") + raise serializers.ValidationError({"password_confirmation": "Passwords do not match."}) if len(password) < 8: - raise serializers.ValidationError("Password must be at least 8 characters long.") + raise serializers.ValidationError({"password": "Password must be at least 8 characters long."}) + # If there are any errors, raise ValidationError data.pop('password_confirmation', None) - return data - + diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 54b2540..2d570bf 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -23,9 +23,11 @@ from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUna from apps.account.models import User from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer from utils.redis import RedisManager +from utils.exceptions import AppAPIException from utils import send_email, is_valid_email from config.settings import base as settings from apps.account.permissions import IsActiveUser +from apps.account.doc import * logger = logging.getLogger(__name__) @@ -39,8 +41,8 @@ class UserRegisterView(CreateAPIView): @swagger_auto_schema( + operation_description=doc_register(), request_body=UserRegisterSerializer, - responses={201: 'User registered successfully', 400: 'Bad request'} ) def post(self, request): serializer = self.get_serializer(data=request.data) @@ -68,6 +70,13 @@ class UserVerifyView(CreateAPIView): permission_classes = [AllowAny] serializer_class = UserVerifySerializer + @swagger_auto_schema( + operation_description=doc_verify(), + request_body=UserVerifySerializer, + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -77,7 +86,7 @@ class UserVerifyView(CreateAPIView): if not verify_data: raise ExpiredCodeException("Verification data not found or expired.") except (ServiceUnavailableException) as e: - return Response({"detail": str(e)}, status=e.status_code) + return AppAPIException({"message": str(e)}, status_code=e.status_code) except ExpiredCodeException: raise ExpiredCodeException("The verification code has expired.") @@ -126,6 +135,14 @@ class UserVerifyView(CreateAPIView): class UserLoginView(CreateAPIView): permission_classes = [AllowAny] serializer_class = UserLoginSerializer + + @swagger_auto_schema( + operation_description=doc_login(), + request_body=UserLoginSerializer, + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -133,7 +150,7 @@ class UserLoginView(CreateAPIView): data = serializer.data user = authenticate(request, username=request.data['email'], password=data['password']) if not user: - raise AuthenticationFailed(_('Unable to log in with provided credentials.')) + raise ValidationError({"email": "Unable to log in with provided credentials."}) user.last_login = timezone.now() user.is_active = True user.save @@ -169,7 +186,11 @@ class UserUpdateView(UpdateAPIView): class UserRecoverPassword(CreateAPIView): serializer_class = UserRecoverPasswordSerializer - + + @swagger_auto_schema( + operation_description=doc_recover(), + request_body=UserRecoverPasswordSerializer, + ) def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -198,6 +219,10 @@ class UserResetPassword(CreateAPIView): serializer_class = UserResetPasswordSerializer permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_description=doc_reset(), + request_body=UserResetPasswordSerializer, + ) def post(self, request, *args, **kwargs): # Get the logged-in user user = request.user @@ -223,7 +248,7 @@ class UserDeleteView(APIView): try: user = request.user if user.email == "admin@gmail.com": - return Response({"detail": "admin"}, status=status.HTTP_204_NO_CONTENT) + raise AppAPIException({"message": "Unable to log in with provided credentials."}, status_code=status.HTTP_204_NO_CONTENT) user.soft_delete() if t := Token.objects.filter(user=user).first(): diff --git a/apps/chat/__init__.py b/apps/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/admin.py b/apps/chat/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/chat/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/chat/apps.py b/apps/chat/apps.py new file mode 100644 index 0000000..2d27770 --- /dev/null +++ b/apps/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.chat' diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py new file mode 100644 index 0000000..5f599ba --- /dev/null +++ b/apps/chat/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.4 on 2024-11-22 19:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course', '0004_auto_20241122_1913'), + ] + + operations = [ + migrations.CreateModel( + name='ChatMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(verbose_name='Message Content')), + ('content_type', models.CharField(choices=[('text', 'Text'), ('file', 'File'), ('audio', 'Audio'), ('image', 'Image')], default='text', max_length=10, verbose_name='Chat Type')), + ('content_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='Content Size (bytes)')), + ('is_to_professor', models.BooleanField(default=False, verbose_name='Is to Professor')), + ('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='Sent At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('is_deleted', models.BooleanField(default=False, verbose_name='Is deleted')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='course.course', verbose_name='Course')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages_received', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_sent', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ], + ), + ] diff --git a/apps/chat/migrations/__init__.py b/apps/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/models.py b/apps/chat/models.py new file mode 100644 index 0000000..7baad17 --- /dev/null +++ b/apps/chat/models.py @@ -0,0 +1,60 @@ + +from django.db import models + +from apps.account.models import User, ProfessorUser +from apps.course.models import Course + + +class ChatMessage(models.Model): + class ChatTypeChoices(models.TextChoices): + TEXT = 'text', 'Text' + FILE = 'file', 'File' + AUDIO = 'audio', 'Audio' + IMAGE = 'image', 'Image' + + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name="messages", + verbose_name="Course" + ) + sender = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="messages_sent", + verbose_name="Sender" + ) + recipient = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="messages_received", + verbose_name="Recipient", + null=True, + blank=True + + ) + content = models.TextField(verbose_name="Message Content") + content_type = models.CharField( + max_length=10, + choices=ChatTypeChoices.choices, + default=ChatTypeChoices.TEXT, + verbose_name="Chat Type" + ) + content_size = models.PositiveIntegerField( + verbose_name="Content Size (bytes)", + blank=True, + null=True + ) + is_to_professor = models.BooleanField(default=False, verbose_name="Is to Professor") + + sent_at = models.DateTimeField(auto_now_add=True, verbose_name="Sent At") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated At") + deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Deleted At") + is_deleted = models.BooleanField(default=False, verbose_name="Is deleted") + + def __str__(self): + return f"Message from {self.sender} to {self.recipient or 'Group'} in {self.course.title}" + + + + \ No newline at end of file diff --git a/apps/account/tests.py b/apps/chat/tests.py similarity index 100% rename from apps/account/tests.py rename to apps/chat/tests.py diff --git a/apps/chat/views.py b/apps/chat/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/chat/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py index 6e8ef48..e86b7ee 100644 --- a/apps/course/admin/__init__.py +++ b/apps/course/admin/__init__.py @@ -1,2 +1,3 @@ from .course import * -from .lesson import * \ No newline at end of file +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index c2313ad..b75ba99 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -1,8 +1,14 @@ + +import os +import hashlib + from django.contrib import admin +from django import forms from ajaxdatatable.admin import AjaxDatatable +from utils.json_editor_field import JsonEditorWidget from apps.course.models import Course, Glossary, Attachment, CourseCategory - +from utils.schema import get_weekly_timing_schema, get_course_feature_schema @@ -11,16 +17,35 @@ class CourseCategoryAdmin(admin.ModelAdmin): list_display = ('name', 'slug') search_fields = ('name',) exclude = ('slug', ) + + + +class CourseForm(forms.ModelForm): + class Meta: + model = Course + fields = '__all__' + # exclude = ('slug',) + widgets = { + 'timing': JsonEditorWidget(attrs={'schema': get_weekly_timing_schema}), + 'features': JsonEditorWidget(attrs={'schema': get_course_feature_schema}), + } + @admin.register(Course) class CourseAdmin(AjaxDatatable): + form = CourseForm list_display = ('title', 'category', 'level', 'status', 'final_price', 'is_online') list_filter = ('status', 'level', 'is_online', 'is_free', 'category') search_fields = ('title', 'description') exclude = ('slug', ) + + + # @admin.display(description='Add Student') + # def _add_student(self, obj): + @@ -35,10 +60,6 @@ class GlossaryAdmin(admin.ModelAdmin): -from django import forms -import hashlib -import os - class AttachmentAdminForm(forms.ModelForm): class Meta: diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py index 0e78236..14ca87e 100644 --- a/apps/course/admin/lesson.py +++ b/apps/course/admin/lesson.py @@ -1,6 +1,6 @@ - from django.contrib import admin -from apps.course.models import Lesson + +from apps.course.models import Lesson, LessonCompletion @@ -15,4 +15,19 @@ class LessonAdmin(admin.ModelAdmin): def get_queryset(self, request): qs = super().get_queryset(request) return qs.order_by('priority') - \ No newline at end of file + + +@admin.register(LessonCompletion) +class LessonCompletionAdmin(admin.ModelAdmin): + list_display = ('student', 'lesson', 'completed_at') + search_fields = ('student__fullname', 'student__email', 'lesson__title', 'lesson__course__title') + list_filter = ('lesson__course', 'completed_at') + ordering = ('-completed_at',) + + def get_readonly_fields(self, request, obj=None): + """ + Make fields readonly if the object already exists. + """ + if obj: + return ['student', 'lesson', 'completed_at'] + return [] \ No newline at end of file diff --git a/apps/course/admin/participant.py b/apps/course/admin/participant.py new file mode 100644 index 0000000..95be293 --- /dev/null +++ b/apps/course/admin/participant.py @@ -0,0 +1,33 @@ +from django.contrib import admin + +from apps.course.models import Participant +from apps.account.models import StudentUser, User + + + +@admin.register(Participant) +class ParticipantAdmin(admin.ModelAdmin): + list_display = ('student', 'course', 'joined_date') + search_fields = ('student__fullname', 'student__email', 'course__title') + list_filter = ('course', 'joined_date') + ordering = ('-joined_date',) + autocomplete_fields = ['student'] # جستجوی پویا برای فیلد دانش‌آموز + + def get_readonly_fields(self, request, obj=None): + """ + Make fields readonly if the object already exists. + """ + if obj: + return ['student', 'course', 'joined_date'] + return [] + + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj is None: # Adding a new participant + # محدود کردن انتخاب دانش‌آموزان به کاربرانی که از نوع StudentUser هستند + form.base_fields['student'].queryset = StudentUser.objects.filter(user_type=User.UserType.STUDENT) + form.base_fields['student'].widget.can_add_related = True # فعال کردن دکمه اضافه کردن + + return form + \ No newline at end of file diff --git a/apps/course/doc.py b/apps/course/doc.py new file mode 100644 index 0000000..d7b2e39 --- /dev/null +++ b/apps/course/doc.py @@ -0,0 +1,410 @@ +def doc_course_participants(): + return """ +# 🐈 Scenario +🛠️ لیست شرکت‌کنندگان دوره + +--- + +## 🚀 درخواست API + +### URL: +``` +GET /api/courses//participants/ +``` + + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیستی از شرکت‌کنندگان دوره بازگردانده شد. | +| `404` | دوره یافت نشد. | +| `500` | مشکل موقتی در سرور. | + +--- + + +### پاسخ موفق: +```json +[ + { + "id": 1, + "fullname": "Ali Rezaei", + "avatar": "https://example.com/avatars/ali_rezaei.jpg", + "email": "ali@example.com", + "phone_number": "+98 912 345 6789", + "info": "Experienced Python Developer", + "skill": "Python, Django, REST API" + } +] +``` +""" + + +def doc_courses_lesson(): + return """ +# 🐈 Scenario +🛠️ لیست درس‌های دوره + +این API برای دریافت لیست درس‌های یک دوره خاص استفاده می‌شود. این لیست شامل اطلاعاتی مانند عنوان، اولویت، مدت زمان، نوع محتوا، لینک ویدئو، و وضعیت تکمیل هر درس می‌باشد. + +(مقدار is_complated مشخص میکند آیا کاربر این درس را گذرانده است +ممکن است درس دارای کوعیز باشد که باید در زیر آ» مانند طرح نمایش داده شود +) +--- +``` + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|-------------------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای درس. | +| `title` | String | عنوان درس. | +| `priority` | Integer | اولویت نمایش درس در لیست دروس. | +| `is_active` | Boolean | آیا درس فعال است یا خیر. | +| `duration` | Integer | مدت زمان درس به دقیقه. | +| `content_type` | String | نوع محتوا (لینک یا فایل). | +| `content_file` | String | فایل مرتبط با درس (در صورت وجود). | +| `video_link` | String | لینک ویدئو برای درس (در صورت آنلاین بودن). | +| `is_complated` | Boolean | آیا کاربر این درس را تکمیل کرده است یا خیر. | +| `quiz` | Object | اطلاعات مرتبط با کوییز درس (در صورت وجود). | + + +### پاسخ موفق: +```json +[ + { + "id": 1, + "title": "Introduction to Variables", + "duration": 30, + "content_type": "link", + "content_file": null, + "video_link": "https://example.com/videos/variables_intro.mp4", + "is_complated": true, + "quiz": { + "title": "", + "description": "", + "is_complated": "", + } + } +] +``` +""" + + + +def doc_courses_my_courses(): + return """ +# 🐈 Scenario +🛠️ دوره‌های من + +این API برای دریافت لیست دوره‌هایی است که کاربر در آن‌ها شرکت کرده است. این شامل دوره‌هایی است که به اتمام رسیده‌اند یا هنوز در حال تکمیل هستند. + +(برای دوره های تکمیل نشده +?completed=false +دوره های تکمیل شده +?completed=true +) +(برای همه دوره های کاربر بدون هیچ مقداری بفرستید) +(در صفحه هوم هم میتوانید دوره هایی که کاربر شرکت کرده است و هنوز تکمیل نشده است را نمایش دهید) + + + +--- + +## 🚀 درخواست API + +### پارامترهای فیلتر +| کلید | نوع داده | توضیحات | +|---------------|-----------|----------------------------------------------------------| +| `completed` | Boolean | اگر `true` باشد، فقط دوره‌هایی که تکمیل شده‌اند را بازمی‌گرداند. | + + +### درخواست کامل: +``` +GET /api/my-courses/?completed=true +``` +""" + + + + +def doc_course_detail(): + return """ +# 🐈 Scenario +🛠️ جزئیات دوره + +--- + +## 💡 نکات مهم: +1. **اطلاعات دسترسی (`access`)**: + - این مقدار نشان می‌دهد که آیا کاربر به این دوره دسترسی دارد یا خیر. + در واقع آیا دانش آموز این دوره است و به درس های این دوره دسترسی دارد + +2. **ویدئو دوره**: + - دوره‌ها می‌توانند شامل لینک ویدئو یا فایل ویدئویی باشند که توسط `video_type` مشخص می‌شود. +3. **تعداد درس‌های تکمیل‌شده**: + - `lessons_complated_count` نشان می‌دهد که چند درس توسط کاربر تکمیل شده است. + (برای به دست آوردن درصد درس های تکمیل شده دانش اموز تعداد کل درس های دوره را بر اساس درس های تکمیل شده دوره توسط دانش آموز محاسبه کنید) +4. **اطلاعات استاد (`professor`)**: + - اطلاعات استاد شامل نام، تصویر و مهارت‌ها برای آشنایی بیشتر با مربی دوره فراهم شده است. +5. برای دیدن درس ها و فایل ها و گلاسوری api +های جدا در نظر گرفته شده است. + +--- + +--- +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|-------------------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای دوره. | +| `title` | String | عنوان دوره. | +| `slug` | String | شناسه یکتای دوره که برای URLها استفاده می‌شود. | +| `category` | Object | اطلاعات دسته‌بندی دوره شامل نام و شناسه. | +| `access` | Boolean | آیا کاربر به این دوره دسترسی دارد یا خیر. | +| `participant_count` | Integer | تعداد شرکت‌کنندگان در این دوره. | +| `professor` | Object | اطلاعات استاد شامل نام، تصویر، و مهارت‌ها. | +| `thumbnail` | String | لینک تصویر کوچک دوره. به صورت ابجکت است | +| `video_type` | String | نوع ویدئو (لینک یا فایل). | +| `video_file` | String | لینک فایل ویدئویی در صورت وجود. | +| `video_link` | String | لینک ویدئو در صورت آنلاین بودن محتوا. | +| `is_online` | Boolean | آیا دوره به صورت آنلاین برگزار می‌شود یا خیر. | +| `level` | String | سطح دوره (beginner, mid, advanced). | +| `duration` | Integer | مدت زمان دوره به ساعت. | +| `lessons_count` | Integer | تعداد درس‌های موجود در این دوره. | +| `lessons_complated_count`| Integer | تعداد درس‌هایی که کاربر تکمیل کرده است. که ممکن است مقدار خالی هم باشد | +| `short_description` | String | توضیح کوتاه در مورد دوره. | +| `status` | String | وضعیت دوره (upcoming, registering, ongoing, finished). | +| `is_free` | Boolean | آیا دوره رایگان است یا خیر. | +| `price` | Decimal | قیمت اصلی دوره در صورت غیر رایگان بودن. | +| `discount_percentage` | Decimal | درصد تخفیف برای دوره. | +| `final_price` | Decimal | قیمت نهایی دوره پس از اعمال تخفیف. | +| `timing` | String | زمان‌بندی برگزاری دوره (مثلاً ساعت‌ها و روزهای برگزاری).'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], | +| `features` | String | ویژگی‌های برجسته دوره. | + +--- +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "id": 1, + "title": "Тажвид м", + "slug": "tazhvid-m", + "category": { + "name": "Таджвид Корана", + "slug": "tadzhvid-korana", + "course_count": 25 + }, + "access": true, + "participant_count": 120, + "professor": { + "id": 2, + "fullname": "rezaa", + "avatar": "http://localhost:8000/media/users/avatars/2024/11/test3.jpeg", + "email": "root@admin.com", + "phone_number": "+98 901 203 1023", + "info": "good", + "skill": null + }, + "thumbnail": {}, + "video_type": "video_link", + "video_file": null, + "video_link": "https:222", + "is_online": true, + "level": "beginner", + "duration": 55, + "lessons_count": 2, + "lessons_complated_count": 0, + "short_description": "Таджвид Корана2", + "status": "upcoming", + "is_free": true, + "price": "0.00", + "discount_percentage": 0, + "final_price": "0.00", + "timing": [ + { + "day": "Monday", + "time": "02:00" + }, + { + "day": "Friday", + "time": "10:00" + } + ], + "features": [ + { + "title": "good" + }, + { + "title": "regood" + } + ] +} +``` + +""" + + + + + + +def doc_course_list(): + return """ +# 🐈 Scenario +🛠️ لیست دوره‌ها + +این API برای لیست کردن دوره‌ها به همراه اطلاعاتی مانند تعداد شرکت‌کنندگان، دسته‌بندی، تصویر کوچک، سطح، مدت زمان و دیگر جزئیات مرتبط استفاده می‌شود. + + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|---------------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای دوره. | +| `title` | String | عنوان دوره. | +| `slug` | String | شناسه یکتای دوره که برای URLها استفاده می‌شود. | +| `participant_count` | Integer | تعداد شرکت‌کنندگانی که در این دوره حضور دارند. | +| `category` | Object | اطلاعات دسته‌بندی دوره شامل نام و شناسه و اسلاک | +| `thumbnail` | String | لینک تصویر کوچک دوره. | +| `is_online` | Boolean | آیا دوره به صورت آنلاین برگزار می‌شود یا خیر. | +| `level` | String | سطح دوره (beginner, mid, advanced). | +| `duration` | Integer | مدت زمان دوره به ساعت. | +| `lessons_count` | Integer | تعداد درس‌های موجود در این دوره. | +| `short_description` | String | توضیح کوتاه در مورد دوره. | +| `status` | String | وضعیت دوره (upcoming, registering, ongoing, finished). | +| `is_free` | Boolean | آیا دوره رایگان است یا خیر. | +| `price` | Decimal | قیمت اصلی دوره در صورت غیر رایگان بودن. | +| `discount_percentage`| Decimal | درصد تخفیف برای دوره. | +| `final_price` | Decimal | قیمت نهایی دوره پس از اعمال تخفیف. | + +--- + + +### پارامترهای فیلتر و جستجو +| کلید | نوع داده | توضیحات | +|---------------|-----------|----------------------------------------------------------| +| `title` | String | عنوان دوره برای جستجو در لیست دوره‌ها. | +| `category_slug` | String | اسلاگ دسته‌بندی دوره برای فیلتر کردن دوره‌ها براساس دسته‌بندی. | +| `status` | String | وضعیت دوره برای فیلتر کردن براساس وضعیت (upcoming, registering, ongoing, finished) | +| `is_free` | Boolean | برای فیلتر کردن دوره‌های رایگان یا غیررایگان. | +| `is_online` | Boolean | برای فیلتر کردن دوره‌های آنلاین یا آفلاین. | + +--- + + + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیستی از دوره‌ها بازگردانده شد. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +[ + { + "id": 1, + "title": "Introduction to Python", + "slug": "introduction-to-python", + "participant_count": 120, + "category": { + "name": "Programming", + "slug": "programming" + }, + "thumbnail": {}, + "is_online": true, + "level": "beginner", + "duration": 180, + "lessons_count": 12, + "short_description": "Learn the basics of Python programming.", + "status": "upcoming", + "is_free": false, + "price": 100.0, + "discount_percentage": 20.0, + "final_price": 80.0 + }, + +] +``` + +""" + + +def doc_course_category(): + return """ +# 🐈 Scenario +🛠️ لیست دسته‌بندی‌های دوره‌ها + +این API برای لیست کردن دسته‌بندی‌های دوره‌ها به همراه تعداد دوره‌های مرتبط با هر دسته‌بندی استفاده می‌شود. + +--- + +## 🚀 درخواست API + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|---------------|-----------|----------------------------------------------------------| +| `name` | String | نام دسته‌بندی دوره. | +| `slug` | String | شناسه یکتای دسته‌بندی که برای URLها استفاده می‌شود. | +| `course_count`| Integer | تعداد دوره‌هایی که در این دسته‌بندی قرار دارند. | + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیستی از دسته‌بندی‌های دوره‌ها بازگردانده شد. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +[ + { + "name": "Programming", + "slug": "programming", + "course_count": 12 + }, + { + "name": "Data Science", + "slug": "data-science", + "course_count": 8 + } +] +``` + +## 📄 نمونه درخواست: + +### درخواست کامل: +``` +GET /api/course-categories/ +``` + +### پاسخ موفق: +```json +[ + { + "name": "Web Development", + "slug": "web-development", + "course_count": 15 + }, + { + "name": "Artificial Intelligence", + "slug": "ai", + "course_count": 10 + } +] +``` +""" diff --git a/apps/course/migrations/0001_initial.py b/apps/course/migrations/0001_initial.py new file mode 100644 index 0000000..1d78597 --- /dev/null +++ b/apps/course/migrations/0001_initial.py @@ -0,0 +1,115 @@ +# Generated by Django 3.2.4 on 2024-11-21 20:46 + +import apps.course.models.course +import apps.course.models.lesson +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image +import utils.schema + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('account', '0003_auto_20241120_1741'), + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Course Title')), + ('slug', models.SlugField(allow_unicode=True, unique=True)), + ('video_type', models.CharField(choices=[('video_file', 'Video File'), ('video_link', 'Video Link')], max_length=20, verbose_name='Vedio Type')), + ('video_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.course.course_file_upload_to)), + ('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link')), + ('is_online', models.BooleanField(default=True, verbose_name='Is Online Course')), + ('level', models.CharField(choices=[('beginner', 'Beginner'), ('mid', 'Mid Level'), ('advanced', 'Advanced')], max_length=10, verbose_name='Course Level')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (in hours)')), + ('lessons_count', models.PositiveIntegerField(verbose_name='Number of Lessons')), + ('description', models.TextField(verbose_name='Course Description')), + ('short_description', models.CharField(blank=True, max_length=500, null=True, verbose_name='Short Description')), + ('status', models.CharField(choices=[('inactive', 'Inactive'), ('upcoming', 'Upcoming'), ('registering', 'Registering'), ('ongoing', 'Ongoing'), ('finished', 'Finished')], default='inactive', max_length=15, verbose_name='Course Status')), + ('is_free', models.BooleanField(default=True, verbose_name='Is Free')), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Course Price')), + ('discount_percentage', models.PositiveIntegerField(default=0, verbose_name='Discount Percentage')), + ('final_price', models.DecimalField(blank=True, decimal_places=2, default=0.0, help_text='This field is automatically calculated based on the discount percentage.', max_digits=10, verbose_name='Course Final Price')), + ('timing', models.JSONField(blank=True, default=utils.schema.default_timing, help_text='The Timing information in JSON format.', null=True, verbose_name='Timing')), + ('features', models.JSONField(blank=True, default=dict, null=True, verbose_name='Course features')), + ], + options={ + 'verbose_name': 'Course', + 'verbose_name_plural': 'Courses', + }, + ), + migrations.CreateModel( + name='CourseCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Category Name')), + ('slug', models.SlugField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Lesson Title')), + ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (in minutes)')), + ('content_type', models.CharField(choices=[('link', 'Link'), ('file', 'File')], max_length=10, verbose_name='Content Type')), + ('content_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.lesson.lesson_file_upload_to)), + ('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.course', verbose_name='Course')), + ], + ), + migrations.CreateModel( + name='Glossary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=555, verbose_name='Glossary Title')), + ('description', models.TextField(verbose_name='Description')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='glossaries', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Glossary', + 'verbose_name_plural': 'Glossary', + 'ordering': ('-id',), + }, + ), + migrations.AddField( + model_name='course', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='course.coursecategory', verbose_name='Category'), + ), + migrations.AddField( + model_name='course', + name='professor', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='account.professoruser'), + ), + migrations.AddField( + model_name='course', + name='thumbnail', + field=filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail'), + ), + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Attachment Title')), + ('file', models.FileField(upload_to=apps.course.models.course.attachment_file_upload_to, verbose_name='Attachment File')), + ('file_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='File Size (in bytes)')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Attachment', + 'verbose_name_plural': 'Attachments', + 'ordering': ('-id',), + }, + ), + ] diff --git a/apps/course/migrations/0002_auto_20241121_2238.py b/apps/course/migrations/0002_auto_20241121_2238.py new file mode 100644 index 0000000..49f4550 --- /dev/null +++ b/apps/course/migrations/0002_auto_20241121_2238.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.4 on 2024-11-21 22:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_auto_20241120_1741'), + ('course', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='lesson', + name='is_active', + field=models.BooleanField(default=True, verbose_name='Is Active'), + ), + migrations.CreateModel( + name='LessonCompletion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_at', models.DateTimeField(auto_now_add=True)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.lesson')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_completions', to='account.studentuser')), + ], + options={ + 'unique_together': {('student', 'lesson')}, + }, + ), + ] diff --git a/apps/course/migrations/0003_participant.py b/apps/course/migrations/0003_participant.py new file mode 100644 index 0000000..1583527 --- /dev/null +++ b/apps/course/migrations/0003_participant.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.4 on 2024-11-21 22:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_auto_20241120_1741'), + ('course', '0002_auto_20241121_2238'), + ] + + operations = [ + migrations.CreateModel( + name='Participant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('joined_date', models.DateTimeField(auto_now_add=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='course.course')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participated_courses', to='account.studentuser')), + ], + options={ + 'unique_together': {('student', 'course')}, + }, + ), + ] diff --git a/apps/course/migrations/0004_auto_20241122_1913.py b/apps/course/migrations/0004_auto_20241122_1913.py new file mode 100644 index 0000000..bdd56db --- /dev/null +++ b/apps/course/migrations/0004_auto_20241122_1913.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.4 on 2024-11-22 19:13 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0003_participant'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='course', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated At'), + ), + migrations.AddField( + model_name='lesson', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='lesson', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated At'), + ), + migrations.AddField( + model_name='lessoncompletion', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + ] diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py index 6e8ef48..e86b7ee 100644 --- a/apps/course/models/__init__.py +++ b/apps/course/models/__init__.py @@ -1,2 +1,3 @@ from .course import * -from .lesson import * \ No newline at end of file +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/models/course.py b/apps/course/models/course.py index b45ac89..b80ba40 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -50,10 +50,10 @@ class Course(models.Model): class StatusChoices(TextChoices): INACTIVE = 'inactive', 'Inactive' # Not Active (does not show) - UPCOMING = 'upcoming', 'Upcoming' # Upcoming (visible but registration not allowed) - REGISTERING = 'registering', 'Registering' # Registering (registration is open) - ONGOING = 'ongoing', 'Ongoing' # Ongoing (course has started, registration closed) - FINISHED = 'finished', 'Finished' # Finished (course has ended) + UPCOMING = 'upcoming', 'Upcoming' # Upcoming (visible but registration not allowed)-Предстоящие + REGISTERING = 'registering', 'Registering' # Registering (registration is open)-регистрация + ONGOING = 'ongoing', 'Ongoing' # Ongoing (course has started, registration closed)-В процессе + FINISHED = 'finished', 'Finished' # Finished (course has ended)-закончился class VedioTypeChoices(models.TextChoices): VIDEO_FILE = 'video_file', 'Video File' @@ -100,10 +100,19 @@ class Course(models.Model): timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing"), help_text=_("The Timing information in JSON format.")) features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + def __str__(self): return self.title + def get_completed_lessons_count(self, student): + return self.lessons.filter(completions__student=student).count() + + def is_student_participant(self, student): + return self.participants.filter(student=student).exists() + def save(self, *args, **kwargs): self.slug = generate_slug_for_model(Course, self.title) diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py index e5d9084..64b9d4f 100644 --- a/apps/course/models/lesson.py +++ b/apps/course/models/lesson.py @@ -1,9 +1,11 @@ import os from django.db import models +from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField from filer.fields.file import FilerFileField +from apps.account.models import StudentUser def lesson_file_upload_to(instance, filename): @@ -20,6 +22,7 @@ class Lesson(models.Model): course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') title = models.CharField(max_length=255, verbose_name='Lesson Title') priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') content_type = models.CharField(max_length=10, choices=ContentTypeChoices.choices, verbose_name='Content Type') content_file = models.FileField( @@ -29,6 +32,36 @@ class Lesson(models.Model): ) video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Video Link') + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): return f"{self.course.title} - {self.title}" + def is_completed_by(self, student): + return self.completions.filter(student=student).exists() + + + +class LessonCompletion(models.Model): + student = models.ForeignKey( + StudentUser, + on_delete=models.CASCADE, + related_name='lesson_completions' + ) + lesson = models.ForeignKey( + Lesson, + on_delete=models.CASCADE, + related_name='completions' + ) + completed_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + + class Meta: + unique_together = ('student', 'lesson') + + def __str__(self): + return f"{self.student.fullname} - {self.lesson.title} - Completed" + + \ No newline at end of file diff --git a/apps/course/models/participant.py b/apps/course/models/participant.py new file mode 100644 index 0000000..8dbd266 --- /dev/null +++ b/apps/course/models/participant.py @@ -0,0 +1,24 @@ + +from django.db import models + + +from apps.account.models import StudentUser +from apps.course.models import Course + + +class Participant(models.Model): + student = models.ForeignKey( + StudentUser, + on_delete=models.CASCADE, + related_name='participated_courses' + ) + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name='participants' + ) + joined_date = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('student', 'course') + \ No newline at end of file diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py index bc1d8a6..6e8ef48 100644 --- a/apps/course/serializers/__init__.py +++ b/apps/course/serializers/__init__.py @@ -1 +1,2 @@ -from .course import * \ No newline at end of file +from .course import * +from .lesson import * \ No newline at end of file diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index bf51772..1a6011e 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -1,9 +1,9 @@ from rest_framework import serializers from dj_filer.admin import get_thumbs -from apps.course.models import Course, CourseCategory, Attachment, Glossary -from apps.account.serializers import UserProfileSerializer +from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant +from apps.account.serializers import UserProfileSerializer @@ -15,15 +15,15 @@ class CourseCategorySerializer(serializers.ModelSerializer): fields = ['name', 'slug', 'course_count'] def get_course_count(self, obj): - # return obj.course_count - return 25 + return obj.course_count class CourseListSerializer(serializers.ModelSerializer): category = CourseCategorySerializer() thumbnail = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() - + lessons_count = serializers.SerializerMethodField() + class Meta: model = Course fields = [ @@ -49,8 +49,14 @@ class CourseListSerializer(serializers.ModelSerializer): return get_thumbs(obj.thumbnail, self.context.get('request')) def get_participant_count(self, obj): - return 120 + return obj.participants.count() + def get_lessons_count(self, obj): + lessons_count = obj.lessons.filter(is_active=True).count() + return max(lessons_count, obj.lessons_count) + + + class CourseDetailSerializer(serializers.ModelSerializer): @@ -58,7 +64,10 @@ class CourseDetailSerializer(serializers.ModelSerializer): professor = UserProfileSerializer() thumbnail = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() - + access = serializers.SerializerMethodField() + lessons_complated_count = serializers.SerializerMethodField() + lessons_count = serializers.SerializerMethodField() + class Meta: model = Course fields = [ @@ -66,6 +75,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'title', 'slug', 'category', + 'access', 'participant_count', 'professor', 'thumbnail', @@ -76,6 +86,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'level', 'duration', 'lessons_count', + 'lessons_complated_count', 'short_description', 'status', 'is_free', @@ -86,13 +97,99 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'features', ] + def get_access(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj): + return False + return True + return False + + def get_is_professor(self, obj): + if professor := self._get_authenticated_user(): + return obj.professor == professor + return False + + def get_lessons_count(self, obj): + lessons_count = obj.lessons.filter(is_active=True).count() + return max(lessons_count, obj.lessons_count) + + + def get_lessons_complated_count(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj): + return None + return self._get_completed_lessons_count(student, obj) + return None + + def _is_participant(self, student, course): + """Helper method to check if a student is a participant in the given course.""" + return Participant.objects.filter(student=student, course=course).exists() + + def _get_authenticated_user(self): + """Helper method to retrieve the authenticated user from the context.""" + request = self.context.get('request') + return request.user if request and request.user.is_authenticated else None + + def _get_completed_lessons_count(self, student, course): + """Helper method to count completed lessons for the student in the given course.""" + return LessonCompletion.objects.filter( + student=student, + lesson__course=course + ).count() + + def get_thumbnail(self, obj): return get_thumbs(obj.thumbnail, self.context.get('request')) def get_participant_count(self, obj): - return 120 + return obj.participants.count() + + + +class MyCourseListSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + thumbnail = serializers.SerializerMethodField() + lessons_complated_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'category', + 'thumbnail', + 'lessons_count', + 'lessons_complated_count', + 'short_description', + 'status', + ] + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + def get_lessons_complated_count(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj): + return None + return self._get_completed_lessons_count(student, obj) + return None + + def _is_participant(self, student, course): + """Helper method to check if a student is a participant in the given course.""" + return Participant.objects.filter(student=student, course=course).exists() + + def _get_authenticated_user(self): + """Helper method to retrieve the authenticated user from the context.""" + request = self.context.get('request') + return request.user if request and request.user.is_authenticated else None + + def _get_completed_lessons_count(self, student, course): + """Helper method to count completed lessons for the student in the given course.""" + return LessonCompletion.objects.filter( + student=student, + lesson__course=course + ).count() class AttachmentSerializer(serializers.ModelSerializer): diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py new file mode 100644 index 0000000..19dc90b --- /dev/null +++ b/apps/course/serializers/lesson.py @@ -0,0 +1,39 @@ + + +from rest_framework import serializers +from apps.course.models import Lesson, Participant, LessonCompletion + + + + + + +class LessonSerializer(serializers.ModelSerializer): + is_complated = serializers.SerializerMethodField() + quiz = serializers.SerializerMethodField() + + class Meta: + model = Lesson + fields = ['id', 'title', 'priority', 'is_active', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quiz'] + + def get_is_complated(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + user = request.user + is_participant = Participant.objects.filter( + student=user, + course=obj.course + ).exists() + + if not is_participant: + return False + + return LessonCompletion.objects.filter( + student=user, + lesson=obj + ).exists() + + + def get_quiz(self, obj): + return {} diff --git a/apps/course/urls.py b/apps/course/urls.py index ab0de13..de80cde 100644 --- a/apps/course/urls.py +++ b/apps/course/urls.py @@ -8,8 +8,12 @@ from . import views urlpatterns = [ path('categories/', views.CourseCategoryAPIView.as_view(), name='course-categories'), path('', views.CourseListAPIView.as_view(), name='course-list'), + path('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'), path('/', views.CourseDetailAPIView.as_view(), name='course-detail'), path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), path('/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), + path('/lessons/', views.LessonListView.as_view(), name='course-lesson-list'), + path('/participants/', views.CourseParticipantsView.as_view(), name='course-participant-list'), + ] diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py index bc1d8a6..e86b7ee 100644 --- a/apps/course/views/__init__.py +++ b/apps/course/views/__init__.py @@ -1 +1,3 @@ -from .course import * \ No newline at end of file +from .course import * +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 20a4d20..3a4bb59 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -1,37 +1,69 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView +from django.db.models import Count, Q, F from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.filters import SearchFilter from apps.course.serializers import ( CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, - AttachmentSerializer, GlossarySerializer + AttachmentSerializer, GlossarySerializer, MyCourseListSerializer ) -from apps.course.models import Course, CourseCategory, Attachment, Glossary - +from apps.course.models import Course, CourseCategory, Attachment, Glossary, Participant +from apps.course.doc import * class CourseCategoryAPIView(ListAPIView): queryset = CourseCategory.objects.all() serializer_class = CourseCategorySerializer + @swagger_auto_schema( + operation_description=doc_course_category(), + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + class CourseListAPIView(ListAPIView): queryset = Course.objects.all().exclude(status=Course.StatusChoices.INACTIVE) serializer_class = CourseListSerializer - # filterset_fields = ['category__slug',] - + filter_backends = [SearchFilter] + search_fields = ['title'] - @swagger_auto_schema(manual_parameters=[ + @swagger_auto_schema( + operation_description=doc_course_list(), + manual_parameters=[ openapi.Parameter( 'category_slug', openapi.IN_QUERY, description="Category of the Course", type=openapi.TYPE_STRING, enum=[category.slug for category in CourseCategory.objects.all()] ), + openapi.Parameter( + 'status', openapi.IN_QUERY, + type=openapi.TYPE_STRING, + description="""Status => + Upcoming (visible but registration not allowed)---Предстоящие + Registering (registration is open)---регистрация + Ongoing (course has started, registration closed)---Впроцессе + Finished (course has ended)---закончился + """, + enum=[status for status in ['upcoming', 'registering', 'ongoing', 'finished']] + ), + openapi.Parameter( + 'is_free', openapi.IN_QUERY, + description="Ценообразование is_free ", + type=openapi.TYPE_BOOLEAN, + ), + openapi.Parameter( + 'is_online', openapi.IN_QUERY, + description="Статус участия is_online ", + type=openapi.TYPE_BOOLEAN, + ), ]) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) @@ -42,7 +74,18 @@ class CourseListAPIView(ListAPIView): filters = request.query_params if category := filters.get('category_slug'): queryset = queryset.filter(category__slug=category) - + if status := filters.get('status'): + queryset = queryset.filter(status=status) + + if is_free := filters.get('is_free'): + is_free = is_free.lower() == 'true' + queryset = queryset.filter( + models.Q(is_free=is_free) | models.Q(price=0) if is_free else models.Q(is_free=False, price__gt=0) + ) + if is_online := filters.get('is_online'): + is_online = is_online.lower() == 'true' + queryset = queryset.filter(is_online=is_online) + return queryset @@ -52,9 +95,51 @@ class CourseDetailAPIView(RetrieveAPIView): queryset = Course.objects.all() serializer_class = CourseDetailSerializer lookup_field = "slug" + + @swagger_auto_schema( + operation_description=doc_course_detail(), + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) +class MyCourseListAPIView(ListAPIView): + serializer_class = MyCourseListSerializer + permission_classes = [IsAuthenticated] + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter( + 'completed', openapi.IN_QUERY, + description="мои курсы completed true", + type=openapi.TYPE_BOOLEAN, + ), + ], + operation_description=doc_courses_my_courses(), + operation_summary="Home", + + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = Course.objects.all() + request = self.request + filters = request.query_params + student = self.request.user + qs = queryset.filter(participants__student=student) + completed_only = filters.get('completed', '').lower() == 'true' + if completed_only: + queryset = queryset.annotate( + total_lessons=Count('lessons', distinct=True), + completed_lessons=Count( + 'lessons__completions', + filter=Q(lessons__completions__student=student), + distinct=True + ) + ).filter(total_lessons=F('completed_lessons')) + return queryset + + class AttachmentListAPIView(ListAPIView): @@ -87,6 +172,8 @@ class AttachmentListAPIView(ListAPIView): class GlossaryListAPIView(ListAPIView): serializer_class = GlossarySerializer + filter_backends = [SearchFilter] + search_fields = ['title', 'description'] def get_queryset(self): course_slug = self.kwargs.get('slug') @@ -95,4 +182,7 @@ class GlossaryListAPIView(ListAPIView): except Course.DoesNotExist: raise NotFound("Course not found") - return Glossary.objects.filter(course=course) \ No newline at end of file + return Glossary.objects.filter(course=course) + + + \ No newline at end of file diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py new file mode 100644 index 0000000..831d1cd --- /dev/null +++ b/apps/course/views/lesson.py @@ -0,0 +1,28 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from django.shortcuts import get_object_or_404 + + +from apps.course.serializers import ( + LessonSerializer +) +from apps.course.models import Course, Lesson +from apps.course.doc import * + + + + +class LessonListView(ListAPIView): + serializer_class = LessonSerializer + queryset = Lesson.objects.filter(is_active=True) + +# doc_courses_lesson + @swagger_auto_schema( + operation_description=doc_courses_lesson(), + ) + def get_queryset(self): + course_slug = self.kwargs.get('slug') + course = get_object_or_404(Course, slug=course_slug) + return self.queryset.filter(course=course).order_by('priority','id') diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py new file mode 100644 index 0000000..2f39fd8 --- /dev/null +++ b/apps/course/views/participant.py @@ -0,0 +1,27 @@ + +from rest_framework.generics import ListAPIView +from rest_framework.exceptions import NotFound +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.account.models import StudentUser +from apps.course.models import Participant, Course +from apps.account.serializers import UserProfileSerializer +from apps.course.doc import * + + + +class CourseParticipantsView(ListAPIView): + serializer_class = UserProfileSerializer + + @swagger_auto_schema( + operation_description=doc_course_participants(), + ) + def get_queryset(self): + course_slug = self.kwargs.get('slug') + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + raise NotFound("Course not found") + + return StudentUser.objects.filter(participated_courses__course=course) diff --git a/config/settings/base.py b/config/settings/base.py index 49787e0..bcceb40 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -43,6 +43,7 @@ LOCAL_APPS = [ 'apps.account.apps.AccountConfig', 'apps.api.apps.ApiConfig', 'apps.course.apps.CourseConfig', + 'apps.chat.apps.ChatConfig', ] THIRD_PARTY_APPS = [ @@ -243,7 +244,8 @@ REST_FRAMEWORK = { 'rest_framework.authentication.TokenAuthentication', # 'rest_framework.authentication.SessionAuthentication', ], - 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' # or OpenAPISchema if using drf_yasg + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', } diff --git a/config/test_auth_middleware.py b/config/test_auth_middleware.py index 19ba241..dcb4da3 100644 --- a/config/test_auth_middleware.py +++ b/config/test_auth_middleware.py @@ -23,6 +23,10 @@ def test_auth_middleware(get_response): t, _ = Token.objects.get_or_create(user=user) request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + user = User.objects.filter(email="mortezaei2324@gmail.com").first() + if user: + t, _ = Token.objects.get_or_create(user=user) + request.META['HTTP_AUTHORIZATION'] = f"Token {t}" return get_response(request) diff --git a/templates/admin/auth/user/add_form.html b/templates/admin/auth/user/add_form.html new file mode 100644 index 0000000..df9b72f --- /dev/null +++ b/templates/admin/auth/user/add_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block form_top %} + {% if not is_popup %} + {% comment %}

{% translate 'First, enter a username and password. Then, you’ll be able to edit more user options.' %}

{% endcomment %} + {% else %} + {% comment %}

{% translate "You can add the details" %}

{% endcomment %} + {% endif %} +{% endblock %} diff --git a/templates/admin/auth/user/change_password.html b/templates/admin/auth/user/change_password.html new file mode 100644 index 0000000..c107161 --- /dev/null +++ b/templates/admin/auth/user/change_password.html @@ -0,0 +1,57 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} +{% load admin_urls %} + +{% block extrastyle %}{{ block.super }}{% endblock %} +{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} +{% block content %}
+{% csrf_token %}{% block form_top %}{% endblock %} + +
+{% if is_popup %}{% endif %} +{% if form.errors %} +

+ {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %} +

+{% endif %} + +

{% blocktranslate with username=original %}Enter a new password for the user {{ username }}.{% endblocktranslate %}

+ +
+ +
+ {{ form.password1.errors }} + {{ form.password1.label_tag }} {{ form.password1 }} + {% if form.password1.help_text %} +
{{ form.password1.help_text|safe }}
+ {% endif %} +
+ +
+ {{ form.password2.errors }} + {{ form.password2.label_tag }} {{ form.password2 }} + {% if form.password2.help_text %} +
{{ form.password2.help_text|safe }}
+ {% endif %} +
+ +
+ +
+ +
+ +
+
+{% endblock %} diff --git a/utils/exceptions.py b/utils/exceptions.py index 89f5a89..147defb 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -2,9 +2,133 @@ from rest_framework.exceptions import APIException from rest_framework import status +from rest_framework.views import exception_handler as drf_exception_handler +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError +def error_response(error_code, message, details=None, status_code=status.HTTP_400_BAD_REQUEST): + # print(f'>>>> {error_code}') + response_data = { + "status": "error", + "code": error_code, + "message": message, + "details": details or {} + } + + return Response(response_data, status=status_code) + + +def exception_handler(exc, context): + # Call REST framework's default exception handler first, + # to get the standard error response. + response = drf_exception_handler(exc, context) + # قالب‌بندی جدید خطاها + formatted_errors = [] + + if response is not None: + # تعیین نوع خطاها + print(f'>>> {type(exc)}/ {exc}') + + if isinstance(exc, ValidationError): + error_code = "validation_error" + message = "There were validation errors." + + if isinstance(response.data, list): + for error in response.data: + formatted_errors.append({ + "field": None, # No specific field when the error is a list + "message": error + }) + + else: + # فرمت کردن خطاها به فرمت جدید + print(f'>>>>>> {response.data}') + for field, errors in response.data.items(): + if isinstance(errors, list): + for error in errors: + # print() + # print(f'>>>> {field}') + formatted_errors.append({ + "field": field, + "message": error + }) + else: + formatted_errors.append({ + "field": field, + "message": errors + }) + + seen_fields = set() + for i in range(len(formatted_errors) - 1, -1, -1): + field = formatted_errors[i]['field'] + if field in seen_fields: + # اگر فیلد تکراری بود، آن را حذف کن + del formatted_errors[i] + else: + # اولین باری که فیلد دیده شده، آن را ثبت کن + seen_fields.add(field) + + elif isinstance(exc, AppAPIException): + error_code = "app_api_error" + message = "An error occurred while processing the request." + for field, errors in response.data.items(): + if isinstance(errors, list): + for error in errors: + formatted_errors.append({ + "message": error + }) + else: + formatted_errors.append({ + "message": errors + }) + + + else: + error_code = "server_error" + message = "An error occurred." + # فرمت کردن خطاها به فرمت جدید + for field, errors in response.data.items(): + if isinstance(errors, list): + for error in errors: + formatted_errors.append({ + "message": error + }) + else: + formatted_errors.append({ + "message": errors + }) + + + + + # تنظیم ساختار جدید برای پاسخ خطا + response.data = { + "status": "error", + "code": error_code, + "status_code": response.status_code, # Adding the status code + "message": message, + "errors": formatted_errors + } + + return response + + +class AppAPIException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'An error occurred while processing the request.' + + def __init__(self, detail=None, status_code=None): + if detail is None: + detail = self.default_detail + if status_code is None: + status_code = self.default_code + else: + self.status_code = status_code + super().__init__(detail, status_code) + + class ExpiredCodeException(APIException): status_code = status.HTTP_410_GONE diff --git a/utils/schema.py b/utils/schema.py index a9112b4..110a701 100644 --- a/utils/schema.py +++ b/utils/schema.py @@ -1,14 +1,52 @@ +from django.utils.translation import gettext_lazy as _ def default_timing(): return { - "saturday": "", - "sunday": "", - "monday": "", - "tuesday": "", - "wednesday": "", - "thursday": "", - "friday": "" + # "saturday": "", + # "sunday": "", + # "monday": "", + # "tuesday": "", + # "wednesday": "", + # "thursday": "", + # "friday": "" + } + + +def get_weekly_timing_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str('Weekly Timing'), + 'properties': { + 'day': { + 'type': 'string', + 'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], + 'title': 'Day', + + }, + 'time': {'type': 'string', 'format': 'time','title': str('Time')}, + } + } + } + + + +def get_course_feature_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Course Features')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + } + } }