Browse Source

dovodi splited !.

master
Mohsen Taba 1 day ago
parent
commit
13d80710ba
  1. 132
      BUGFIX_REPORT.md
  2. 80
      CLAUDE.md
  3. 134
      OPTIMIZATION_PLAN.md
  4. 607
      adjustemnts.md
  5. 258
      apps/account/admin/user.py
  6. 55
      apps/account/management/commands/migrate_user_roles.py
  7. 133
      apps/account/tests/test_account_urls.py
  8. 138
      apps/account/tests/test_multiple_roles.py
  9. 31
      apps/api/tests.py
  10. 39
      apps/article/tests.py
  11. 0
      apps/blog/__init__.py
  12. 126
      apps/blog/admin.py
  13. 7
      apps/blog/apps.py
  14. 30
      apps/blog/management/commands/fix_empty_blog_fields.py
  15. 367
      apps/blog/management/commands/seed_blog_data.py
  16. 238
      apps/blog/migrations/0001_initial.py
  17. 23
      apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py
  18. 76
      apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py
  19. 0
      apps/blog/migrations/__init__.py
  20. 208
      apps/blog/models.py
  21. 142
      apps/blog/serializers.py
  22. 3
      apps/blog/tests.py
  23. 24
      apps/blog/urls.py
  24. 183
      apps/blog/views.py
  25. 63
      apps/bookmark/tests.py
  26. 0
      apps/certificate/__init__.py
  27. 45
      apps/certificate/admin.py
  28. 6
      apps/certificate/apps.py
  29. 69
      apps/certificate/migrations/0001_initial.py
  30. 36
      apps/certificate/migrations/0002_alter_certificate_course_and_more.py
  31. 0
      apps/certificate/migrations/__init__.py
  32. 28
      apps/certificate/models.py
  33. 50
      apps/certificate/serializers.py
  34. 3
      apps/certificate/tests.py
  35. 11
      apps/certificate/urls.py
  36. 56
      apps/certificate/views.py
  37. 0
      apps/chat/__init__.py
  38. 366
      apps/chat/admin.py
  39. 6
      apps/chat/apps.py
  40. 1
      apps/chat/management/__init__.py
  41. 62
      apps/chat/management/commands/README.md
  42. 1
      apps/chat/management/commands/__init__.py
  43. 79
      apps/chat/management/commands/clear_chat_data.py
  44. 221
      apps/chat/migrations/0001_initial.py
  45. 18
      apps/chat/migrations/0002_roommessage_is_locked.py
  46. 35
      apps/chat/migrations/0003_alter_chatmessage_options_and_more.py
  47. 0
      apps/chat/migrations/__init__.py
  48. 184
      apps/chat/models.py
  49. 3
      apps/chat/tests.py
  50. 3
      apps/chat/views.py
  51. 0
      apps/course/__init__.py
  52. 4
      apps/course/admin/__init__.py
  53. 569
      apps/course/admin/course.py
  54. 126
      apps/course/admin/lesson.py
  55. 177
      apps/course/admin/live_session.py
  56. 33
      apps/course/admin/participant.py
  57. 181
      apps/course/admin/professor_base.py
  58. 9
      apps/course/apps.py
  59. 42
      apps/course/data/category.json
  60. 430
      apps/course/doc.py
  61. 0
      apps/course/management/__init__.py
  62. 0
      apps/course/management/commands/__init__.py
  63. 134
      apps/course/management/commands/clear_course_data.py
  64. 1007
      apps/course/migrations/0001_initial.py
  65. 23
      apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py
  66. 23
      apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py
  67. 58
      apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py
  68. 19
      apps/course/migrations/0005_alter_course_discount_percentage.py
  69. 31
      apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py
  70. 0
      apps/course/migrations/__init__.py
  71. 4
      apps/course/models/__init__.py
  72. 275
      apps/course/models/course.py
  73. 152
      apps/course/models/lesson.py
  74. 189
      apps/course/models/live_session.py
  75. 34
      apps/course/models/participant.py
  76. 5
      apps/course/serializers/__init__.py
  77. 436
      apps/course/serializers/course.py
  78. 85
      apps/course/serializers/lesson.py
  79. 67
      apps/course/serializers/online.py
  80. 17
      apps/course/serializers/participant.py
  81. 29
      apps/course/serializers/professor.py
  82. 3
      apps/course/services/__init__.py
  83. 1660
      apps/course/services/api.md
  84. 151
      apps/course/services/plugnmeet.py
  85. 82
      apps/course/signals.py
  86. 29
      apps/course/templates/course/add_student_form.html
  87. 3
      apps/course/tests.py
  88. 1
      apps/course/tests/__init__.py
  89. 182
      apps/course/tests/test_live_session_api.py
  90. 216
      apps/course/tests/test_multiple_roles_api.py
  91. 113
      apps/course/tests/test_professor_api.py
  92. 312
      apps/course/token-join-guide.md
  93. 37
      apps/course/urls.py
  94. 6
      apps/course/views/__init__.py
  95. 822
      apps/course/views/course.py
  96. 168
      apps/course/views/lesson.py
  97. 661
      apps/course/views/live_session.py
  98. 77
      apps/course/views/participant.py
  99. 205
      apps/course/views/professor.py
  100. 279
      apps/course/views/webhook.py

132
BUGFIX_REPORT.md

@ -1,132 +0,0 @@
# Bug Fix Report: Article Pinned-Collections 500 Error
## Issue Summary
**Endpoint**: `api/article/pinned-collections/`
**Error**: 500 Internal Server Error
**Root Cause**: `AttributeError: type object 'ServiceChoices' has no attribute 'ARTICLE'`
## Problem Analysis
### Location of Errors
The error occurred in two locations in `apps/article/views.py`:
1. **Line 49** - `PinnedArticleCollectionListView.list()`:
```python
bookmarks_count = Bookmark.objects.filter(
service=Bookmark.ServiceChoices.ARTICLE, # ❌ ARTICLE didn't exist
).count()
```
2. **Line 156** - `ArticleListAPIView.get_queryset()`:
```python
bookmarked_ids = Bookmark.objects.filter(
user=self.request.user,
service=Bookmark.ServiceChoices.ARTICLE, # ❌ ARTICLE didn't exist
status=True
).values_list('content_id', flat=True)
```
### Root Cause
The `Bookmark` model's `ServiceChoices` enum only had 4 services defined:
- ✓ LIBRARY = 'library'
- ✓ PODCAST = 'podcast'
- ✓ HADITH = 'hadith'
- ✓ VIDEO = 'video'
- ❌ ARTICLE (missing!)
The article views were attempting to use `ServiceChoices.ARTICLE` which didn't exist, causing an `AttributeError` and resulting in a 500 error.
## Solution Implemented
### Changes Made
#### 1. Updated Bookmark Model (`apps/bookmark/models/bookmark.py`)
**Added ARTICLE to ServiceChoices**:
```python
class ServiceChoices(models.TextChoices):
LIBRARY = 'library', 'Library'
PODCAST = 'podcast', 'Podcast'
HADITH = 'hadith', 'Hadith'
VIDEO = 'video', 'Video'
ARTICLE = 'article', 'Article' # ✓ Added
```
**Updated validate_content_exists method**:
```python
elif service == cls.ServiceChoices.ARTICLE:
from apps.article.models import Article
return Article.objects.filter(id=content_id).exists()
```
#### 2. Database Migration
Created and applied migration: `0003_add_article_service_choice.py`
```bash
python manage.py makemigrations bookmark --name add_article_service_choice
python manage.py migrate bookmark
```
## Verification
### Test Results
All tests passed successfully:
```
✓ ServiceChoices.ARTICLE exists and has correct value
✓ 'article' is in ServiceChoices.choices
All available services: ['library', 'podcast', 'hadith', 'video', 'article']
✓ validate_content_exists(ARTICLE, 99999) = False (expected False)
✓ validate_content_exists(ARTICLE, 1) = True (expected True)
✓ Bookmark count query works: 0 article bookmarks found
✓ Bookmarked articles filter works: []
```
### Affected Endpoints Now Working
- ✓ `GET /api/article/pinned-collections/` - Returns 200 OK
- ✓ `GET /api/article/list/?is_bookmark=true` - Filters bookmarked articles
- ✓ `POST /api/bookmarks/add/` - Can bookmark articles (service=article)
- ✓ `DELETE /api/bookmarks/remove/` - Can remove article bookmarks
## Impact Assessment
### Positive Impact
- ✓ Fixed 500 error on article pinned-collections endpoint
- ✓ Enabled bookmark functionality for articles (consistent with other services)
- ✓ Users can now bookmark/unbookmark articles
- ✓ Article list can be filtered by bookmarked status
### No Breaking Changes
- ✓ Backward compatible - existing bookmarks unaffected
- ✓ All other services (library, podcast, hadith, video) continue working
- ✓ No API contract changes
## Files Modified
1. `apps/bookmark/models/bookmark.py` - Added ARTICLE service choice
2. `apps/bookmark/migrations/0003_add_article_service_choice.py` - Database migration
3. `test_article_endpoint.py` - Verification test script (can be removed)
4. `BUGFIX_REPORT.md` - This report
## Recommendations
### Immediate Actions
- ✓ Deploy the fix to production
- ✓ Monitor error logs to confirm 500 errors are resolved
- ✓ Test bookmark functionality for articles in production
### Future Improvements
1. Add integration tests for article bookmark operations
2. Consider adding API documentation for article bookmark endpoints
3. Add validation to prevent similar issues when adding new services
## Conclusion
The 500 error in `api/article/pinned-collections/` has been successfully resolved by adding the missing `ARTICLE` service choice to the Bookmark model. The fix is minimal, backward-compatible, and enables full bookmark functionality for articles, bringing it in line with other services in the application.
---
**Fixed by**: Kombai AI Assistant
**Date**: 2025
**Status**: ✓ Resolved and Tested

80
CLAUDE.md

@ -1,80 +0,0 @@
# CodeViz Research Context
> **Note**: This file contains research context from CodeViz. Most recent contexts are at the bottom.
---
## Research Query
در مورد چت میشه بگی
*Session: 169492aff6d1e2bbd34a3c87fd82786e | Generated: 7/22/2025, 4:26:02 PM*
### Analysis Summary
# Chat Functionality Overview
The chat functionality in this codebase is primarily handled by the **`chat`** application, located at [apps/chat/](apps/chat/). This application is responsible for managing chat-related data models, administrative interfaces, and potentially views for handling chat interactions.
## High-Level Architecture
The **`chat`** application is a self-contained Django application designed to manage real-time or asynchronous chat features. It integrates with the main project through its models, views, and potentially URL configurations.
## Mid-Level Components
The **`chat`** application consists of several key components:
### **`apps.py`** - Application Configuration
This file defines the configuration for the chat application, including its name.
- **Purpose**: Configures the Django application.
- **Internal Parts**: Contains the `ChatConfig` class.
- **External Relationships**: Registered in the project's settings to enable the `chat` application.
- [ChatConfig](apps/chat/apps.py)
### **`models.py`** - Data Models
This file defines the database models for chat-related entities. These models represent the structure of the data stored in the database for chat messages, conversations, or related concepts.
- **Purpose**: Defines the data structures for chat entities.
- **Internal Parts**: Contains Django model classes (e.g., `Message`, `Conversation`, `ChatRoom`, etc., depending on implementation).
- **External Relationships**: Interacts with the database to store and retrieve chat data. These models are likely related to user models from the `account` app.
- [models.py](apps/chat/models.py)
### **`admin.py`** - Admin Interface
This file registers the chat models with the Django administrative interface, allowing superusers to manage chat data directly from the admin panel.
- **Purpose**: Provides an administrative interface for managing chat data.
- **Internal Parts**: Contains `admin.site.register()` calls for chat models.
- **External Relationships**: Integrates with Django's built-in admin site.
- [admin.py](apps/chat/admin.py)
### **`views.py`** - Views and Logic
This file would contain the logic for handling requests related to chat, such as sending messages, retrieving chat history, or managing chat rooms.
- **Purpose**: Implements the business logic and presentation for chat features.
- **Internal Parts**: Contains Django views (function-based or class-based) that handle HTTP requests.
- **External Relationships**: Interacts with the models to perform CRUD operations on chat data and potentially renders templates or returns API responses.
- [views.py](apps/chat/views.py)
### **`migrations/`** - Database Migrations
This directory contains the database migration files generated by Django, which manage changes to the chat application's database schema.
- **Purpose**: Manages database schema evolution for chat models.
- **Internal Parts**: Contains numbered migration files (e.g., `0001_initial.py`).
- **External Relationships**: Applied by Django's ORM to update the database schema.
- [migrations/](apps/chat/migrations/)
### **`management/`** - Custom Management Commands
This directory might contain custom Django management commands specific to the chat application, such as commands for cleaning up old messages or seeding chat data.
- **Purpose**: Provides custom command-line utilities for chat-related tasks.
- **Internal Parts**: Contains Python files defining custom commands.
- **External Relationships**: Executed via `python manage.py <command_name>`.
- [management/](apps/chat/management/)
### **`templates/`** - HTML Templates
This directory would hold any HTML templates used by the chat application's views for rendering web pages.
- **Purpose**: Stores HTML templates for rendering chat-related user interfaces.
- **Internal Parts**: Contains `.html` files.
- **External Relationships**: Used by Django views to render dynamic content.
- [templates/](apps/chat/templates/)
## Integration with Project URLs
The chat application's URLs are likely included in the main project's URL configuration, typically found in [config/urls.py](config/urls.py). This file acts as the central routing mechanism for the entire application, directing requests to the appropriate views within the `chat` app or other applications.
- [urls.py](config/urls.py)

134
OPTIMIZATION_PLAN.md

@ -1,134 +0,0 @@
# Django Database Query Optimization Plan
## Phase 1: Analysis Complete ✅
### Current Issues Identified:
1. **N+1 Query Problems** in course, video, library, article, podcast views
2. **Missing select_related/prefetch_related** optimizations
3. **Inefficient serializer methods** with individual database queries
4. **Missing database indexes** on frequently queried fields
5. **Suboptimal queryset patterns** in views and admin
## Phase 2: Query Optimization Implementation Plan
### Step 1: Course App Optimization
**Priority: HIGH** (Core functionality)
#### 1.1 Course Views Optimization
- **CourseListAPIView**: Add select_related for professor, category
- **CourseDetailAPIView**: Add prefetch_related for lessons, attachments, glossaries, participants
- **MyCourseListAPIView**: Optimize participant and completion queries
#### 1.2 Course Serializers Optimization
- **CourseListSerializer**: Optimize professor and category access
- **CourseDetailSerializer**: Optimize all related object access
- **CourseLessonSerializer**: Optimize lesson completion and quiz queries
#### 1.3 Course Admin Optimization
- **CourseAdmin**: Add select_related/prefetch_related to get_queryset
- **ParticipantAdmin**: Optimize student and course queries
### Step 2: Video App Optimization
**Priority: HIGH** (Heavy content usage)
#### 2.1 Video Views Optimization
- **VideoListAPIView**: Add prefetch_related for categories, collections
- **VideoDetailAPIView**: Optimize playlist and bookmark queries
- **VideoCollectionViews**: Optimize video relationships
#### 2.2 Video Serializers Optimization
- **VideoDetailSerializer**: Optimize bookmark, rate, and playlist queries
- **VideoCollectionSerializer**: Optimize video access
### Step 3: Library App Optimization
**Priority: HIGH** (Heavy content usage)
#### 3.1 Library Views Optimization
- **BookListView**: Add prefetch_related for categories, collections
- **BookDetailView**: Optimize bookmark and rate queries
- **BookCollectionViews**: Optimize book relationships
#### 3.2 Library Serializers Optimization
- **BookSerializer**: Optimize bookmark and rate queries
- **BookCollectionSerializer**: Optimize book access
### Step 4: Article & Podcast Apps Optimization
**Priority: MEDIUM**
#### 4.1 Similar patterns to Video/Library apps
- Apply same optimization patterns
- Focus on category and collection relationships
- Optimize bookmark and rate queries
### Step 5: Account App Optimization
**Priority: MEDIUM**
#### 5.1 User Admin Optimization
- **UserAdmin**: Already has some prefetch_related, enhance further
- **StudentUserAdmin**: Optimize course participation queries
### Step 6: Chat App Optimization
**Priority: MEDIUM**
#### 6.1 Chat Views Optimization
- **RoomMessage queries**: Add select_related for initiator, recipient, course
- **ChatMessage queries**: Add select_related for sender, room
### Step 7: Bookmark & Rate System Optimization
**Priority: HIGH** (Used across all content types)
#### 7.1 Bookmark Queries Optimization
- Optimize bookmark status checks in serializers
- Add bulk bookmark queries where possible
## Phase 3: Database Indexing Plan
### Step 1: Primary Indexes
- Add indexes on status fields (all models)
- Add indexes on created_at, updated_at fields
- Add indexes on slug fields
### Step 2: Foreign Key Indexes
- Ensure all ForeignKey fields have indexes
- Add composite indexes for common query patterns
### Step 3: Composite Indexes
- (user_id, service, status) for Bookmark model
- (course_id, student_id) for Participant model
- (status, created_at) for content models
## Phase 4: Implementation Order
### Week 1: Course App (Core functionality)
1. Course views optimization
2. Course serializers optimization
3. Course admin optimization
4. Add course-related indexes
### Week 2: Content Apps (Video, Library)
1. Video app optimization
2. Library app optimization
3. Add content-related indexes
### Week 3: Remaining Apps
1. Article and Podcast apps
2. Account app enhancements
3. Chat app optimization
4. Bookmark system optimization
### Week 4: Final Optimizations
1. Remaining indexes
2. Performance testing
3. Query analysis and fine-tuning
## Success Metrics
- Reduce average response time by 50-70%
- Reduce database query count per request by 60-80%
- Maintain exact same API response format
- Zero breaking changes to existing functionality
## Implementation Strategy
- One optimization at a time
- Test each change individually
- Maintain backward compatibility
- Monitor performance improvements

607
adjustemnts.md

@ -1,607 +0,0 @@
عالی! حالا که نمونه واقعی از تنظیمات adjustment را دیدم، می‌توانم توضیح کاملی ارائه دهم:
## 📅 **توضیح کامل تنظیمات تقویم قمری (Lunar Calendar Adjustments)**
### 🎯 **هدف کلی**
این تنظیمات برای **تطبیق تقویم قمری** با تقویم میلادی و مدیریت اختلافات محاسباتی بین تقویم‌های مختلف طراحی شده است.
### 📊 **ساختار داده‌ها**
#### **1. آرایه اصلی**
```json
[
{ "adjust": 0, "current": 0, "map": {...} }, // تنظیمات پیش‌فرض
{ "adjust": -1, "current": 0, "map": {...} }, // تنظیمات منفی
{ "adjust": 1, "current": 0, "map": {...} } // تنظیمات مثبت
]
```
#### **2. فیلدهای هر تنظیم**
- **`adjust`**: مقدار تطبیق (0, -1, +1)
- **`current`**: وضعیت فعلی (همیشه 0)
- **`map`**: نقشه سال‌های قمری
### 🗓️ **نقشه سال‌های قمری**
```json
"map": {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29],
"1445": [354, 30, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 29],
"1446": [355, 30, 30, 30, 29, 30, 30, 29, 30, 29, 29, 29, 30],
"1447": [355, 29, 30, 30, 29, 30, 30, 29, 30, 29, 29, 30, 29]
}
```
### 🔢 **تفسیر اعداد**
#### **ساختار هر سال:**
- **عدد اول**: تعداد کل روزهای سال (354 یا 355)
- **12 عدد بعدی**: تعداد روزهای هر ماه (29 یا 30)
#### **مثال سال 1444:**
```json
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
```
- **354 روز** کل سال
- **محرم**: 30 روز
- **صفر**: 30 روز
- **ربیع‌الاول**: 29 روز
- **ربیع‌الثانی**: 30 روز
- **جمادی‌الاول**: 29 روز
- **جمادی‌الثانی**: 29 روز
- **رجب**: 30 روز
- **شعبان**: 29 روز
- **رمضان**: 30 روز
- **شوال**: 29 روز
- **ذی‌القعده**: 30 روز
- **ذی‌الحجه**: 29 روز
### ⚙️ **سه حالت تطبیق**
#### **1. حالت پیش‌فرض (`adjust: 0`)**
- بدون تطبیق اضافی
- محاسبات استاندارد تقویم قمری
#### **2. حالت تطبیق منفی (`adjust: -1`)**
- یک روز از محاسبات کم می‌شود
- برای تصحیح اختلافات محاسباتی
#### **3. حالت تطبیق مثبت (`adjust: 1`)**
- یک روز به محاسبات اضافه می‌شود
- برای تصحیح اختلافات محاسباتی
### 🔄 **نحوه استفاده در API**
```python
# در کد Python
adjustment_config = get_config('calendar__Adjustment')
config_data = json.loads(adjustment_config)
# انتخاب تنظیمات بر اساس نیاز
for config in config_data:
if config['adjust'] == 0: # حالت پیش‌فرض
lunar_calendar_map = config['map']
break
```
### 🎯 **کاربرد عملی**
#### **1. تبدیل تاریخ**
```javascript
// تبدیل تاریخ میلادی به قمری
function convertToLunar(georgianDate, adjustment = 0) {
const config = adjustmentConfigs.find(c => c.adjust === adjustment);
const lunarMap = config.map;
// محاسبات تبدیل با استفاده از نقشه قمری
}
```
#### **2. محاسبه مناسبت‌ها**
```javascript
// محاسبه تاریخ عید فطر
function calculateEidFitr(year) {
const config = getAdjustmentConfig();
const lunarYear = config.map[year];
// محاسبه بر اساس روزهای ماه رمضان
}
```
### 📱 **مثال استفاده در فرانت‌اند**
```javascript
// دریافت تنظیمات از API
fetch('/calendar/adjustemnts/')
.then(response => response.json())
.then(configs => {
// انتخاب تنظیمات مناسب
const defaultConfig = configs.find(c => c.adjust === 0);
const lunarCalendar = defaultConfig.map;
// استفاده برای نمایش تقویم
displayLunarCalendar(lunarCalendar);
});
```
### 🔧 **مدیریت تنظیمات**
- **تغییر تنظیمات**: از طریق پنل ادمین Django
- **بروزرسانی**: تنظیمات فوراً در API اعمال می‌شود
- **پشتیبان‌گیری**: تنظیمات در دیتابیس ذخیره می‌شوند
### 🎨 **مزایای این سیستم**
1. **انعطاف‌پذیری**: امکان تطبیق با روش‌های مختلف محاسبه
2. **دقت**: تصحیح اختلافات محاسباتی
3. **سازگاری**: پشتیبانی از تقویم‌های مختلف
4. **مدیریت آسان**: تغییر تنظیمات بدون تغییر کد
این سیستم به شما امکان مدیریت دقیق تقویم قمری و تطبیق آن با نیازهای مختلف را می‌دهد.
## 🔍 **توضیح عمیق و جامع سیستم تطبیق تقویم قمری**
### 🌙 **چرا این سیستم وجود دارد؟**
#### **1. مشکل اصلی: اختلاف در محاسبه تقویم قمری**
تقویم قمری بر اساس چرخه ماه (29.5 روز) محاسبه می‌شود، اما:
- **سال قمری**: 354 یا 355 روز
- **سال میلادی**: 365 یا 366 روز
- **اختلاف سالانه**: حدود 11 روز
#### **2. چالش‌های محاسباتی**
```javascript
// مشکل: محاسبه دقیق تقویم قمری
const lunarMonth = 29.53059; // روز
const lunarYear = lunarMonth * 12; // 354.36708 روز
// اما سال قمری باید عدد صحیح باشد!
```
#### **3. روش‌های مختلف محاسبه**
- **روش نجومی**: بر اساس رصد ماه
- **روش محاسباتی**: الگوریتم‌های ریاضی
- **روش تقریبی**: فرمول‌های ساده‌شده
### 🎯 **کاربردهای عملی**
#### **1. مدیریت مناسبت‌های مذهبی**
```javascript
// محاسبه تاریخ عید فطر
function calculateEidFitr(year) {
const config = getAdjustmentConfig();
const lunarMap = config.map[year];
// رمضان همیشه 29 یا 30 روز است
const ramadanDays = lunarMap[9]; // ماه نهم (رمضان)
if (ramadanDays === 29) {
return "عید فطر در روز 29 رمضان";
} else {
return "عید فطر در روز 30 رمضان";
}
}
```
#### **2. تبدیل تاریخ‌ها**
```javascript
// تبدیل تاریخ میلادی به قمری
function convertToLunar(georgianDate, adjustment = 0) {
const config = getAdjustmentConfig(adjustment);
const lunarMap = config.map;
// محاسبه روزهای گذشته از ابتدای سال
let totalDays = calculateDaysFromStart(georgianDate);
// تطبیق با تقویم قمری
totalDays += adjustment; // اعمال تنظیمات
// پیدا کردن ماه و روز قمری
return findLunarMonthAndDay(totalDays, lunarMap);
}
```
#### **3. نمایش تقویم ترکیبی**
```javascript
// نمایش همزمان تقویم میلادی و قمری
function displayHybridCalendar(year) {
const config = getAdjustmentConfig();
const lunarMap = config.map[year];
// ایجاد تقویم میلادی
const georgianCalendar = createGeorgianCalendar(year);
// اضافه کردن تاریخ‌های قمری
georgianCalendar.forEach(day => {
day.lunarDate = convertToLunar(day.date, config.adjust);
});
return georgianCalendar;
}
```
### 🔧 **سه حالت تطبیق و کاربرد آنها**
#### **1. حالت پیش‌فرض (`adjust: 0`)**
```javascript
// استفاده برای:
// - نمایش عمومی تقویم
// - محاسبات استاندارد
// - اکثر کاربران
const standardConfig = {
adjust: 0,
current: 0,
map: {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
}
};
```
#### **2. حالت تطبیق منفی (`adjust: -1`)**
```javascript
// استفاده برای:
// - تصحیح اختلافات محاسباتی
// - تطبیق با رصدهای نجومی
// - مناطق جغرافیایی خاص
const negativeAdjustConfig = {
adjust: -1,
current: 0,
map: {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
}
};
// مثال: اگر رصد ماه نشان دهد که رمضان 28 روز است
// اما محاسبات 29 روز نشان می‌دهد
```
#### **3. حالت تطبیق مثبت (`adjust: 1`)**
```javascript
// استفاده برای:
// - تصحیح اختلافات محاسباتی
// - تطبیق با تقویم‌های رسمی
// - مناطق جغرافیایی خاص
const positiveAdjustConfig = {
adjust: 1,
current: 0,
map: {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
}
};
// مثال: اگر تقویم رسمی کشور رمضان 31 روز نشان دهد
// اما محاسبات 30 روز نشان می‌دهد
```
### 🌍 **کاربردهای جغرافیایی**
#### **1. مناطق مختلف جهان**
```javascript
// تنظیمات بر اساس منطقه جغرافیایی
const regionalConfigs = {
"iran": { adjust: 0, name: "تقویم رسمی ایران" },
"saudi": { adjust: -1, name: "تقویم عربستان" },
"turkey": { adjust: 1, name: "تقویم ترکیه" },
"malaysia": { adjust: 0, name: "تقویم مالزی" }
};
```
#### **2. تطبیق با تقویم‌های رسمی**
```javascript
// تطبیق با تقویم رسمی کشورها
function getOfficialCalendar(country, year) {
const regionalConfig = regionalConfigs[country];
const baseConfig = getAdjustmentConfig(regionalConfig.adjust);
return {
country: country,
year: year,
calendar: baseConfig.map[year],
adjustment: regionalConfig.adjust
};
}
```
### 📱 **کاربرد در اپلیکیشن‌ها**
#### **1. اپلیکیشن‌های مذهبی**
```javascript
// محاسبه زمان نماز
function calculatePrayerTimes(date, location) {
const lunarDate = convertToLunar(date, getAdjustmentForLocation(location));
// محاسبه زمان نماز بر اساس تاریخ قمری
return {
fajr: calculateFajrTime(lunarDate),
dhuhr: calculateDhuhrTime(lunarDate),
asr: calculateAsrTime(lunarDate),
maghrib: calculateMaghribTime(lunarDate),
isha: calculateIshaTime(lunarDate)
};
}
```
#### **2. اپلیکیشن‌های تقویم**
```javascript
// نمایش تقویم ترکیبی
function displayCalendar(year, month) {
const config = getAdjustmentConfig();
const lunarMap = config.map[year];
// ایجاد تقویم میلادی
const georgianDays = getGeorgianDays(year, month);
// اضافه کردن تاریخ‌های قمری
const hybridDays = georgianDays.map(day => ({
...day,
lunar: convertToLunar(day.date, config.adjust),
isHoliday: isLunarHoliday(day.date, lunarMap)
}));
return hybridDays;
}
```
### 🔄 **مدیریت پویای تنظیمات**
#### **1. تغییر تنظیمات در زمان اجرا**
```javascript
// تغییر تنظیمات بدون restart
function updateAdjustmentConfig(newConfig) {
// ذخیره در دیتابیس
saveConfigToDatabase(newConfig);
// بروزرسانی کش
updateCache('calendar__Adjustment', newConfig);
// اطلاع‌رسانی به کلاینت‌ها
broadcastConfigUpdate(newConfig);
}
```
#### **2. A/B Testing تنظیمات**
```javascript
// تست تنظیمات مختلف
function testAdjustmentConfigs(userGroup) {
const configs = {
'group_a': { adjust: 0, name: 'پیش‌فرض' },
'group_b': { adjust: -1, name: 'تطبیق منفی' },
'group_c': { adjust: 1, name: 'تطبیق مثبت' }
};
return configs[userGroup] || configs['group_a'];
}
```
### 🎨 **مزایای این سیستم**
#### **1. انعطاف‌پذیری**
- امکان تطبیق با روش‌های مختلف محاسبه
- پشتیبانی از مناطق جغرافیایی مختلف
- قابلیت تغییر بدون تغییر کد
#### **2. دقت**
- تصحیح اختلافات محاسباتی
- تطبیق با رصدهای نجومی
- پشتیبانی از تقویم‌های رسمی
#### **3. مقیاس‌پذیری**
- پشتیبانی از سال‌های متعدد
- امکان اضافه کردن تنظیمات جدید
- مدیریت آسان از طریق پنل ادمین
#### **4. سازگاری**
- کار با تقویم‌های مختلف
- پشتیبانی از زبان‌های مختلف
- تطبیق با استانداردهای بین‌المللی
### 🚀 **نتیجه‌گیری**
این سیستم یک راه‌حل جامع برای مدیریت تقویم قمری است که:
- **مشکلات محاسباتی** را حل می‌کند
- **انعطاف‌پذیری** لازم را فراهم می‌کند
- **دقت** مورد نیاز را تضمین می‌کند
- **مدیریت آسان** تنظیمات را امکان‌پذیر می‌سازد
این سیستم به شما امکان مدیریت دقیق و انعطاف‌پذیر تقویم قمری را می‌دهد و می‌تواند با نیازهای مختلف کاربران و مناطق جغرافیایی تطبیق یابد.
نمونه پاسخ:
[
{
"adjust": 0,
"current": 0,
"map": {
"1444": [
354,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30,
29
],
"1445": [
354,
30,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
29
],
"1446": [
355,
30,
30,
30,
29,
30,
30,
29,
30,
29,
29,
29,
30
],
"1447": [
355,
29,
30,
30,
29,
30,
30,
29,
30,
29,
29,
30,
29
]
}
},
{
"adjust": -1,
"current": 0,
"map": {
"1444": [
354,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30,
29
],
"1445": [
354,
30,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30
],
"1446": [
355,
30,
30,
30,
29,
30,
30,
29,
30,
29,
30,
29,
29
],
"1447": [
355,
29,
30,
30,
29,
30,
30,
29,
30,
29,
29,
30,
29
]
}
},
{
"adjust": 1,
"current": 0,
"map": {
"1444": [
354,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30,
29
],
"1445": [
354,
30,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
29
],
"1446": [
355,
30,
30,
30,
29,
30,
30,
29,
30,
29,
30,
29,
29
],
"1447": [
355,
29,
30,
30,
29,
30,
30,
29,
30,
29,
29,
30,
29
]
}
}
]

258
apps/account/admin/user.py

@ -18,7 +18,6 @@ from unfold.contrib.filters.admin import RangeDateTimeFilter
# Import Models
from apps.account.models import User, ClientUser, StudentUser, ProfessorUser, LocationHistory
from apps.course.models import Participant
# Import Admin Sites from utils
from utils.admin import project_admin_site, dovoodi_admin_site , is_dovoodi_panel
@ -198,261 +197,6 @@ class GuestUserAdmin(UserAdmin):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-"
class StudentParticipantInline(StackedInline):
"""Inline to show courses a student has joined"""
model = Participant
extra = 0
readonly_fields = ('course', 'joined_date', 'course_status', 'course_professor')
fields = ('course', 'course_status', 'course_professor', 'joined_date', 'is_active')
verbose_name = _('Course Participation')
verbose_name_plural = _('Course Participations')
autocomplete_fields = ['course']
tab = True
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('course', 'course__professor')
@admin.display(description=_('Course Status'))
def course_status(self, obj):
if obj.course:
return obj.course.get_status_display()
return '-'
@admin.display(description=_('Professor'))
def course_professor(self, obj):
if obj.course and obj.course.professor:
return obj.course.professor.fullname or obj.course.professor.email
return '-'
def has_add_permission(self, request, obj=None):
return True
def has_change_permission(self, request, obj=None):
return True
def has_delete_permission(self, request, obj=None):
return True
class StudentUserAdmin(UserAdmin):
form = UserAdminChangeForm
add_form = UserAdminCreationForm
list_display = ('display_header', 'email', 'gender', 'display_age', 'courses_count')
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': (('fullname', 'email'), 'phone_number', 'avatar', 'birthdate', 'gender'),
}),
(_('Location'), {
'fields': (('city', 'country'),),
'classes': ('collapse',),
}),
(_('password'), {
'fields': ('password1', 'password2',),
'classes': ('collapse',),
}),
)
inlines = [StudentParticipantInline, LocationHistoryInline]
@display(description=_("Student"), header=True)
def display_header(self, instance: StudentUser):
avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
return [
instance.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 36,
"borderless": True,
},
]
@display(description=_("Age"))
def display_age(self, instance: StudentUser):
from datetime import date
if not instance.birthdate:
return "-"
today = date.today()
birthdate = instance.birthdate
age = today.year - birthdate.year - ((today.month, today.day) < (birthdate.month, birthdate.day))
formatted_date = birthdate.strftime("%Y-%m-%d")
return format_html('<span title="{}">{}</span>', _("Born on {date}").format(date=formatted_date), age)
@display(description=_("Courses"), dropdown=True)
def courses_count(self, instance: StudentUser):
total = instance.participated_courses.count()
items = []
for participant in instance.participated_courses.all():
course = participant.course
title = format_html(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
course.title,
course.id
)
items.append({"title": title})
if total == 0:
return "-"
return {
"title": ngettext("{total} course", "{total} courses", total).format(total=total),
"items": items,
"striped": True,
}
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related(
"participated_courses",
"participated_courses__course",
)
class CourseTableSection(TableSection):
verbose_name = _("Course Categories")
related_name = "courses"
height = 380
fields = ["title", "status", "edit_link"]
def edit_link(self, instance):
return format_html(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
instance.id
)
edit_link.short_description = _("Edit")
class ProfessorUpgradeForm(forms.ModelForm):
existing_user = forms.ModelChoiceField(
queryset=User.objects.filter(is_active=True, email__isnull=False).exclude(groups__name="Professor Group"),
required=True,
label=_("Select Existing User"),
help_text=_("Choose an existing user to upgrade to Professor."),
widget=UnfoldAdminSelectWidget,
)
class Meta:
model = ProfessorUser
fields = ("existing_user", "is_active", "is_staff", "is_superuser", "groups")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'groups' in self.fields:
self.fields['groups'].required = False
def _post_clean(self):
# جلوگیری از اعتبارسنجی مدل خالی برای جلوگیری از ارور فیلدهای اجباری
pass
def save(self, commit=True):
# کاربر موجود (که هنوز پروفسور نیست) را می‌گیریم
user = self.cleaned_data.get('existing_user')
# ابتدا user_type را تغییر می‌دهیم تا با Manager پروفسور سازگار شود
user.user_type = User.UserType.PROFESSOR
user.is_active = self.cleaned_data.get('is_active', user.is_active)
user.is_staff = self.cleaned_data.get('is_staff', user.is_staff)
user.is_superuser = self.cleaned_data.get('is_superuser', user.is_superuser)
user.save() # ذخیره با مدل User
# حالا که user_type آپدیت شد، می‌توانیم آن را به عنوان ProfessorUser واکشی کنیم
prof_user = ProfessorUser.objects.get(pk=user.pk)
# برای ذخیره‌سازی ManyToMany (مثل groups)، باید instance فرم ست شود
self.instance = prof_user
def save_m2m():
groups = self.cleaned_data.get('groups')
if groups is not None:
self.instance.groups.set(groups)
# اضافه‌کردن کاربر به گروه پروفسورها و ساخت اسلاگ (در صورت نیاز)
self.instance.ensure_professor_profile(commit=True)
self.save_m2m = save_m2m
if commit:
self.save_m2m()
return prof_user
class ProfessorUserAdmin(UserAdmin):
form = UserAdminChangeForm
add_form = ProfessorUpgradeForm # <--- آپدیت شد به فرم ارتقا
list_display = ('display_header', 'email', 'courses_count')
list_sections = [CourseTableSection]
save_as = True
# بازنویسی کامل فیلدست‌های صفحه Add (ساخت)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('existing_user',),
}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
'classes': ('wide',),
}),
)
@display(description=_("Professor"), header=True)
def display_header(self, instance: ProfessorUser):
avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
return [
instance.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 50,
"borderless": True,
"squared": True,
},
]
@display(description=_("Courses"), dropdown=True)
def courses_count(self, instance: ProfessorUser):
total = instance.courses.count()
items = []
for course in instance.courses.all():
title = format_html(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
course.title,
course.id
)
items.append({"title": title})
if total == 0:
return "-"
return {
"title": ngettext("{total} course", "{total} courses", total).format(total=total),
"items": items,
"striped": True,
}
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("courses")
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
list_display = ('name', 'permissions_count')
@ -490,8 +234,6 @@ except admin.sites.AlreadyRegistered:
# B. PROJECT ADMIN SITE (Imam Javad)
project_admin_site.register(User, UserAdmin)
project_admin_site.register(ClientUser, GuestUserAdmin)
project_admin_site.register(StudentUser, StudentUserAdmin)
project_admin_site.register(ProfessorUser, ProfessorUserAdmin)
project_admin_site.register(Group, GroupAdmin)
# C. DOVOODI ADMIN SITE

55
apps/account/management/commands/migrate_user_roles.py

@ -4,7 +4,6 @@ Management command برای migration داده‌های موجود به سیست
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group
from apps.account.models import User
from apps.course.models import Course, Participant
class Command(BaseCommand):
@ -31,8 +30,6 @@ class Command(BaseCommand):
# Migration کاربران بر اساس user_type فعلی
self.migrate_user_types(dry_run)
# Migration کاربرانی که هم استاد و هم دانش‌آموز هستند
self.migrate_professor_students(dry_run)
self.stdout.write(
self.style.SUCCESS('Migration completed successfully!')
@ -83,58 +80,6 @@ class Command(BaseCommand):
self.style.ERROR(f'Group {expected_group_name} does not exist')
)
def migrate_professor_students(self, dry_run):
"""شناسایی و migration کاربرانی که هم استاد و هم دانش‌آموز هستند"""
# کاربرانی که دوره ساخته‌اند (استاد هستند)
professors = User.objects.filter(courses__isnull=False).distinct()
# کاربرانی که در دوره شرکت کرده‌اند (دانش‌آموز هستند)
students = User.objects.filter(participated_courses__isnull=False).distinct()
# کاربرانی که هم استاد و هم دانش‌آموز هستند
professor_students = professors.filter(
id__in=students.values_list('id', flat=True)
)
self.stdout.write(
f'Found {professor_students.count()} users who are both professors and students'
)
for user in professor_students:
# اطمینان از اینکه در هر دو گروه هستند
professor_group_exists = user.groups.filter(name="Professor Group").exists()
student_group_exists = user.groups.filter(name="Student Group").exists()
if not professor_group_exists:
if dry_run:
self.stdout.write(
f'Would add professor role to user {user.email}'
)
else:
user.add_role('professor')
self.stdout.write(
f'Added professor role to user {user.email}'
)
if not student_group_exists:
if dry_run:
self.stdout.write(
f'Would add student role to user {user.email}'
)
else:
user.add_role('student')
self.stdout.write(
f'Added student role to user {user.email}'
)
# نمایش آمار
courses_taught = Course.objects.filter(professor=user).count()
courses_enrolled = Participant.objects.filter(student=user).count()
self.stdout.write(
f' User {user.email}: teaches {courses_taught} courses, '
f'enrolled in {courses_enrolled} courses'
)
def get_user_statistics(self):
"""نمایش آمار کاربران"""

133
apps/account/tests/test_account_urls.py

@ -0,0 +1,133 @@
from django.urls import reverse
from rest_framework.test import APITestCase
class AccountURLResolutionTests(APITestCase):
"""
Test suite to ensure all account, registration, login, profile, and notification API endpoints
resolve and execute cleanly.
"""
def setUp(self):
from dj_language.models import Language
Language.objects.get_or_create(id=69, defaults={'code': 'fa', 'name': 'Persian', 'status': True, 'countries': []})
def test_location_info_endpoint(self):
"""Test location-info endpoint is accessible"""
url = reverse('location-info')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_reverse_location_info_endpoint(self):
"""Test reverse-location-info endpoint is accessible"""
url = reverse('reverse-location-info')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_region_info_endpoint(self):
"""Test region-info endpoint is accessible"""
url = reverse('region-info')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_user_register_endpoint(self):
"""Test register endpoint is accessible (POST)"""
url = reverse('user-register')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_web_user_register_endpoint(self):
"""Test web register endpoint is accessible (POST)"""
url = reverse('web-user-register')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_verify_endpoint(self):
"""Test verify endpoint is accessible (POST)"""
url = reverse('user-verify')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_login_endpoint(self):
"""Test login endpoint is accessible (POST)"""
url = reverse('user-login')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_guest_endpoint(self):
"""Test guest endpoint is accessible (POST)"""
url = reverse('user-guest')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_exchange_token_endpoint(self):
"""Test exchange token endpoint is accessible (POST)"""
url = reverse('exchange-token')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_location_history_endpoint(self):
"""Test location update endpoint is accessible (POST)"""
url = reverse('user-location-history')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_location_info_by_coordinates_endpoint(self):
"""Test location-info by coordinates endpoint is accessible"""
url = reverse('location-info-by-coordinates')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_user_profile_endpoint(self):
"""Test user profile endpoint is accessible"""
url = reverse('user-profile')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_user_recover_endpoint(self):
"""Test recover password endpoint is accessible (POST)"""
url = reverse('user-recover')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_reset_endpoint(self):
"""Test reset password endpoint is accessible (POST)"""
url = reverse('user-reset')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_notif_endpoint(self):
"""Test user notification list endpoint is accessible"""
url = reverse('user-notif')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_user_notif_read_all_endpoint(self):
"""Test notifications read-all endpoint is accessible (POST)"""
url = reverse('user-notif-read-all')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_send_notif_endpoint(self):
"""Test send notification endpoint is accessible (POST)"""
url = reverse('user-send-notif')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_user_update_endpoint(self):
"""Test user profile update endpoint is accessible (PUT)"""
url = reverse('user-update')
response = self.client.put(url)
self.assertLess(response.status_code, 500)
def test_user_delete_endpoint(self):
"""Test user delete endpoint is accessible (DELETE)"""
url = reverse('user-delete')
response = self.client.delete(url)
self.assertLess(response.status_code, 500)
def test_update_fcm_endpoint(self):
"""Test update FCM endpoint is accessible (POST)"""
url = reverse('update-fcm')
response = self.client.post(url)
self.assertLess(response.status_code, 500)

138
apps/account/tests/test_multiple_roles.py

@ -4,8 +4,6 @@
from django.test import TestCase
from django.contrib.auth.models import Group
from apps.account.models import User
from apps.course.models import Course, CourseCategory, Participant
from apps.transaction.models import TransactionParticipant
class MultipleRolesTestCase(TestCase):
@ -26,11 +24,6 @@ class MultipleRolesTestCase(TestCase):
self.user.language = None
self.user.save()
# ایجاد دسته‌بندی دوره
self.category = CourseCategory.objects.create(
name='Test Category',
slug='test-category'
)
def test_user_can_have_multiple_roles(self):
"""تست اینکه کاربر می‌تواند چندین نقش داشته باشد"""
@ -66,90 +59,6 @@ class MultipleRolesTestCase(TestCase):
# نقش اصلی باید student شود
self.assertEqual(self.user.primary_role, User.UserType.STUDENT)
def test_course_creation_and_enrollment(self):
"""تست ایجاد دوره و ثبت‌نام در دوره دیگر"""
# کاربر نقش professor می‌گیرد
self.user.add_role('professor')
# ایجاد دوره
course1 = Course.objects.create(
title='Test Course 1',
slug='test-course-1',
category=self.category,
professor=self.user,
level='beginner',
duration=10,
lessons_count=5,
description='Test description'
)
# بررسی اینکه کاربر می‌تواند دوره را مدیریت کند
self.assertTrue(self.user.can_manage_course(course1))
# کاربر دیگری دوره دیگری می‌سازد
other_user = User.objects.create_user(
email='other@example.com',
fullname='Other User',
password='testpass123'
)
other_user.language = None
other_user.save()
other_user.add_role('professor')
course2 = Course.objects.create(
title='Test Course 2',
slug='test-course-2',
category=self.category,
professor=other_user,
level='beginner',
duration=10,
lessons_count=5,
description='Test description 2'
)
# کاربر اول در دوره دوم شرکت می‌کند
self.user.add_role('student')
participant = Participant.objects.create(
student=self.user,
course=course2
)
# بررسی نقش‌ها
self.assertTrue(self.user.has_role('professor')) # هنوز استاد است
self.assertTrue(self.user.has_role('student')) # و دانش‌آموز هم هست
# بررسی دسترسی‌ها
self.assertTrue(self.user.can_manage_course(course1)) # دوره خودش
self.assertFalse(self.user.can_manage_course(course2)) # دوره دیگری
def test_transaction_preserves_professor_role(self):
"""تست اینکه transaction نقش professor را حفظ می‌کند"""
# کاربر استاد می‌شود
self.user.add_role('professor')
# ایجاد دوره
course = Course.objects.create(
title='Test Course',
slug='test-course',
category=self.category,
professor=self.user,
level='beginner',
duration=10,
lessons_count=5,
description='Test description',
is_free=True
)
# شبیه‌سازی transaction (کاربر در دوره‌ای شرکت می‌کند)
if not self.user.has_role('student'):
self.user.add_role('student')
# بررسی اینکه هر دو نقش حفظ شده‌اند
self.assertTrue(self.user.has_role('professor'))
self.assertTrue(self.user.has_role('student'))
# نقش اصلی باید professor باشد
self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR)
def test_permissions(self):
"""تست دسترسی‌ها"""
@ -191,50 +100,3 @@ class MultipleRolesTestCase(TestCase):
self.user.refresh_from_db()
self.assertEqual(self.user.user_type_based_on_groups, User.UserType.CLIENT)
def test_admin_priority_over_professor(self):
"""تست اولویت admin بر professor"""
# کاربر هم admin و هم professor است
self.user.add_role('admin')
self.user.add_role('professor')
self.user.is_staff = True
self.user.save()
# ایجاد دوره
course = Course.objects.create(
title='Test Course',
slug='test-course',
category=self.category,
professor=self.user,
level='beginner',
duration=10,
lessons_count=5,
description='Test description'
)
# admin باید دسترسی کامل داشته باشد
self.assertTrue(self.user.can_manage_course(course))
self.assertTrue(self.user.can_teach_course())
# حتی اگر دوره متعلق به کس دیگری باشد
other_user = User.objects.create_user(
email='other@example.com',
fullname='Other User',
password='testpass123'
)
other_user.language = None
other_user.save()
other_user.add_role('professor')
other_course = Course.objects.create(
title='Other Course',
slug='other-course',
category=self.category,
professor=other_user,
level='beginner',
duration=10,
lessons_count=5,
description='Other description'
)
# admin باید به دوره دیگران هم دسترسی داشته باشد
self.assertTrue(self.user.can_manage_course(other_course))

31
apps/api/tests.py

@ -1,3 +1,30 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APITestCase
# Create your tests here.
class ApiURLResolutionTests(APITestCase):
"""
Test suite to ensure general api app endpoints resolve and execute cleanly.
"""
def test_api_home_endpoint(self):
"""Test home endpoint is accessible via direct path"""
response = self.client.get('/api/test/')
self.assertLess(response.status_code, 500)
def test_api_countries_endpoint(self):
"""Test countries endpoint is accessible via direct path"""
response = self.client.get('/api/test/countries/')
self.assertLess(response.status_code, 500)
def test_comment_list_endpoint(self):
"""Test comment list endpoint is accessible"""
url = reverse('comment-list')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_appversion_list_endpoint(self):
"""Test app version list endpoint is accessible"""
url = reverse('appversion-list')
response = self.client.get(url)
self.assertLess(response.status_code, 500)

39
apps/article/tests.py

@ -1,3 +1,38 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APITestCase
# Create your tests here.
class ArticleURLResolutionTests(APITestCase):
"""
Test suite to ensure all article API endpoints resolve and execute cleanly.
"""
def test_category_list_endpoint(self):
"""Test article categories endpoint is accessible"""
url = reverse('article:category-list')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_pinned_collection_list_endpoint(self):
"""Test article pinned collections endpoint is accessible"""
url = reverse('article:pinned-collection-list')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_collection_list_endpoint(self):
"""Test article collections endpoint is accessible"""
url = reverse('article:collection-list')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_article_list_endpoint(self):
"""Test article list endpoint is accessible"""
url = reverse('article:podcast-list')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_article_detail_endpoint(self):
"""Test article detail endpoint is accessible (may return 404 if no data)"""
url = reverse('article:podcast-detail', kwargs={'slug': 'test-article'})
response = self.client.get(url)
self.assertLess(response.status_code, 500)

0
apps/blog/__init__.py

126
apps/blog/admin.py

@ -1,126 +0,0 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from unfold.decorators import display
from unfold.admin import ModelAdmin, TabularInline, StackedInline
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.widgets import UnfoldAdminTextareaWidget, UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget
from utils.multilang_json_widget import MultiLanguageJSONWidget
from django import forms
from .models import Blog, BlogContent
from utils.admin import project_admin_site
class BlogContentForm(forms.ModelForm):
"""
Custom form for BlogContent to use WysiwygWidget for content field
"""
class Meta:
model = BlogContent
fields = '__all__'
widgets = {
'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminExpandableTextareaWidget),
}
class BlogAdminForm(forms.ModelForm):
class Meta:
model = Blog
fields = '__all__'
widgets = {
# You can switch between UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget,UnfoldAdminTextareaWidget or WysiwygWidget
'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminExpandableTextareaWidget),
'slogan': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextareaWidget),
'summary': MultiLanguageJSONWidget(input_widget_class=WysiwygWidget),
'slug': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget),
}
# ADD THIS METHOD:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Explicitly tell the form these fields are required
# so the admin template renders the red star
self.fields['title'].required = True
self.fields['slogan'].required = True
class BlogContentInline(StackedInline):
"""
Inline admin for BlogContent in Blog admin
"""
model = BlogContent
form = BlogContentForm
extra = 1
fields = ('title', 'content', 'slug', 'image', 'order')
ordering = ['order']
@admin.register(Blog, site=project_admin_site)
class BlogAdmin(ModelAdmin):
"""
Admin interface for Blog model using Django unfold
"""
form = BlogAdminForm
list_display = ('title_info', 'slogan', 'views_count', 'created_at', 'updated_at')
list_filter = ('created_at', 'updated_at')
search_fields = ('title', 'slogan', 'summary')
# prepopulated_fields = {'slug': ('title',)}
readonly_fields = ('views_count', 'created_at', 'updated_at')
fieldsets = (
(_('Basic Information'), {
'fields': ('title', 'slug', 'thumbnail', 'slogan')
}),
(_('Content'), {
'fields': ('summary',)
}),
(_('Statistics'), {
'fields': ('views_count',),
'classes': ('collapse',)
}),
(_('Timestamps'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
inlines = [BlogContentInline]
@display(description=_('Title'), )
def title_info(self, obj):
return str(obj.title)
def get_queryset(self, request):
queryset = super().get_queryset(request)
print(f'--get_queryset-->{queryset}')
for blog in queryset:
print(f'-get_queryset-blog-->{blog.title}')
return queryset.prefetch_related('contents')
@admin.register(BlogContent, site=project_admin_site)
class BlogContentAdmin(ModelAdmin):
"""
Admin interface for BlogContent model using Django unfold
"""
form = BlogContentForm
list_display = ('title_info', 'blog', 'order', 'created_at', 'updated_at')
list_filter = ('blog', 'created_at', 'updated_at')
search_fields = ('title', 'content', 'blog__title')
list_select_related = ('blog',)
fieldsets = (
(_('Basic Information'), {
'fields': ('blog', 'title', 'slug', 'order')
}),
(_('Content'), {
'fields': ('content', 'image')
}),
(_('Timestamps'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
@display(description=_('Title'), )
def title_info(self, obj):
return str(obj.title)

7
apps/blog/apps.py

@ -1,7 +0,0 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.blog'
verbose_name = 'Blog'

30
apps/blog/management/commands/fix_empty_blog_fields.py

@ -1,30 +0,0 @@
from django.core.management.base import BaseCommand
from apps.blog.models import Blog
class Command(BaseCommand):
help = 'Populate empty title and slogan fields in Blog records with default JSON structures.'
def handle(self, *args, **kwargs):
blogs = Blog.objects.all()
updated_count = 0
for blog in blogs:
needs_update = False
# Check if title is logically empty (None, [], {}, or "")
if not blog.title:
# Setting a default structure based on your model's docstring
blog.title = [{"title": "Default Blog Title", "language_code": "en"}]
needs_update = True
# Check if slogan is logically empty
if not blog.slogan:
blog.slogan = [{"text": "Default Blog Slogan", "language_code": "en"}]
needs_update = True
if needs_update:
# Use update_fields for performance and to prevent overriding other concurrent saves
blog.save(update_fields=['title', 'slogan'])
updated_count += 1
self.stdout.write(self.style.SUCCESS(f'Successfully checked all blogs and updated {updated_count} records.'))

367
apps/blog/management/commands/seed_blog_data.py

@ -1,367 +0,0 @@
import os
import random
import uuid
from typing import List, Dict
from django.conf import settings
from django.core.management.base import BaseCommand
from django.core.files import File
from apps.blog.models import Blog, BlogContent
def build_multilang_list(values: Dict[str, str], value_key: str = "title") -> List[Dict[str, str]]:
"""
Convert a dict like {'en': '...', 'fa': '...', 'ru': '...'} into the project's
JSONField list schema: [{'language_code': 'en', 'title': '...'}, ...]
value_key controls whether we store under 'title' (for titles) or 'text' (for content).
"""
return [{"language_code": code, value_key: text} for code, text in values.items()]
def get_seed_images() -> List[str]:
"""
Load available image file paths from BASE_DIR/seeds/images/
"""
base = os.path.join(settings.BASE_DIR, "seeds", "images")
if not os.path.isdir(base):
return []
files = []
for name in os.listdir(base):
lower = name.lower()
if lower.endswith((".jpg", ".jpeg", ".png", ".webp")):
files.append(os.path.join(base, name))
return files
def pick_image_path(images: List[str]) -> str:
"""
Randomly pick an image path from the provided list.
"""
if not images:
return ""
return random.choice(images)
def generate_topics() -> List[Dict[str, Dict[str, str]]]:
"""
Build 20 topics based on prophets and imams to satisfy the requested domains.
Each topic is a mapping for three languages: en, fa, ru.
"""
prophets = [
{"en": "Prophet Muhammad", "fa": "حضرت محمد (ص)", "ru": "Пророк Мухаммад"},
{"en": "Prophet Musa", "fa": "حضرت موسی (ع)", "ru": "Пророк Муса"},
{"en": "Prophet Isa", "fa": "حضرت عیسی (ع)", "ru": "Пророк Иса"},
{"en": "Prophet Ibrahim", "fa": "حضرت ابراهیم (ع)", "ru": "Пророк Ибрахим"},
{"en": "Prophet Nuh", "fa": "حضرت نوح (ع)", "ru": "Пророк Нух"},
{"en": "Prophet Yusuf", "fa": "حضرت یوسف (ع)", "ru": "Пророк Юсуф"},
{"en": "Prophet Yaqub", "fa": "حضرت یعقوب (ع)", "ru": "Пророк Якуб"},
{"en": "Prophet Dawud", "fa": "حضرت داوود (ع)", "ru": "Пророк Давуд"},
]
imams = [
{"en": "Imam Ali", "fa": "امام علی (ع)", "ru": "Имам Али"},
{"en": "Imam Hasan", "fa": "امام حسن (ع)", "ru": "Имам Хасан"},
{"en": "Imam Husayn", "fa": "امام حسین (ع)", "ru": "Имам Хусейн"},
{"en": "Imam Sajjad", "fa": "امام سجاد (ع)", "ru": "Имам Саджад"},
{"en": "Imam Baqir", "fa": "امام باقر (ع)", "ru": "Имам Бакир"},
{"en": "Imam Sadiq", "fa": "امام صادق (ع)", "ru": "Имам Садык"},
{"en": "Imam Kadhim", "fa": "امام کاظم (ع)", "ru": "Имам Казим"},
{"en": "Imam Reza", "fa": "امام رضا (ع)", "ru": "Имам Реза"},
{"en": "Imam Jawad", "fa": "امام جواد (ع)", "ru": "Имам Джавад"},
{"en": "Imam Hadi", "fa": "امام هادی (ع)", "ru": "Имам Хади"},
{"en": "Imam Askari", "fa": "امام عسکری (ع)", "ru": "Имам Аскари"},
{"en": "Imam Mahdi", "fa": "امام مهدی (عج)", "ru": "Имам Махди"},
]
topics = prophets + imams
return topics[:20]
def content_sections(name_en: str, name_fa: str, name_ru: str) -> List[Dict[str, Dict[str, str]]]:
"""
Build 10 narrative anecdotal content sections per blog, tailored to the blog's subject (prophet/imam),
with rich multilingual texts (fa, en, ru). Each section is a self-contained story (حکایت/История).
"""
sections = []
sections.append({
"title": {
"en": f"Anecdote: Early Life Kindness of {name_en}",
"fa": f"حکایت: مهربانی در کودکی {name_fa}",
"ru": f"История: Доброе сердце в детстве {name_ru}",
},
"text": {
"en": f"As a child, {name_en} was noted for uncommon kindness. One cold morning a neighbor had no bread, "
f"so {name_en} shared the family portion and said, 'Provision grows when shared.' "
f"The town remembered this as a lesson that compassion is the seed of community.",
"fa": f"{name_fa} از همان کودکی به مهربانی شناخته می‌شد. صبحی سرد، همسایه‌ای نان نداشت؛ "
f"{name_fa} سهم خانواده را بخشید و گفت: «روزی وقتی تقسیم شود، افزون می‌گردد.» "
f"آن رفتار درسی شد برای شهر که شفقت، بذر اجتماع است.",
"ru": f"С детства {name_ru} отличался редкой добротой. В холодное утро у соседа не было хлеба, "
f"и {name_ru} поделился семейной долей, сказав: «Истинный удел умножается, когда им делятся». "
f"Так люди усвоили урок о сострадании как основе общины.",
},
})
sections.append({
"title": {
"en": f"Anecdote: First Signs of Wisdom of {name_en}",
"fa": f"حکایت: نشانه‌های نخستین حکمت {name_fa}",
"ru": f"История: Первые признаки мудрости {name_ru}",
},
"text": {
"en": f"In youth, a dispute arose over a simple matter. While others raised their voices, "
f"{name_en} asked both sides to repeat their words slowly. "
f"By listening with fairness, {name_en} settled the matter gently and taught that calm clarity reveals truth.",
"fa": f"در جوانی، نزاعی بر سر مسئله‌ای ساده درگرفت. هنگامی که دیگران صدا بلند کرده بودند، "
f"{name_fa} از هر دو طرف خواست آرام و دقیق سخن بگویند. "
f"با گوش سپردن منصفانه، نزاع به نرمی پایان یافت و روشن شد که آرامش، حقیقت را آشکار می‌کند.",
"ru": f"В юности возник спор по пустяку. Пока голоса накалялись, "
f"{name_ru} попросил обе стороны говорить медленно и ясно. "
f"Выслушав справедливо, {name_ru} примирил спорящих и показал, что спокойная ясность открывает истину.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Compassion for the Poor by {name_en}",
"fa": f"حکایت: شفقت بر نیازمندان از سوی {name_fa}",
"ru": f"История: Сострадание к нуждающимся от {name_ru}",
},
"text": {
"en": f"A traveler arrived hungry and ashamed. {name_en} prepared food with their own hands and invited the traveler "
f"to sit as an honored guest. People learned that dignity grows where compassion leads.",
"fa": f"مسافری گرسنه و شرمسار فرا رسید. {name_fa} خود دست به کار شد، طعامی مهیا کرد و مسافر را "
f"چون مهمانی گرامی نشاند. مردم آموختند که کرامت، در سایهٔ پیشگامیِ شفقت می‌روید.",
"ru": f"Пришел путник голодный и смущенный. {name_ru} собственноручно приготовил еду и усадил его как почётного гостя. "
f"Люди поняли, что достоинство расцветает там, где впереди идет сострадание.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Patience Under Trial of {name_en}",
"fa": f"حکایت: صبر در امتحان {name_fa}",
"ru": f"История: Терпение в испытании {name_ru}",
},
"text": {
"en": f"Hard days came with whispers and blame. {name_en} answered with patience, refusing to return harshness with harshness. "
f"In time, those who criticized felt softened and sought forgiveness.",
"fa": f"روزهای دشوار با زمزمه‌ها و سرزنش‌ها همراه شد. {name_fa} با صبر پاسخ گفت و به تندی، تندی نکرد. "
f"با گذر زمان، دلِ ملامت‌گران نرم شد و پوزش خواستند.",
"ru": f"Настали трудные дни с шепотом упреков. {name_ru} отвечал терпением и не платил жесткостью за жесткость. "
f"Со временем сердца порицавших смягчились, и они попросили прощения.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Justice in a Dispute by {name_en}",
"fa": f"حکایت: عدالت در یک نزاع به روایت {name_fa}",
"ru": f"История: Справедливость в споре у {name_ru}",
},
"text": {
"en": f"Two neighbors quarreled over a wall. {name_en} measured the ground, heard each claim, and decided "
f"with equity—neither fully winning nor losing. They accepted, seeing justice as balance, not bias.",
"fa": f"دو همسایه بر سر دیواری به نزاع افتادند. {name_fa} زمین را اندازه گرفت، سخن هر دو را شنید "
f"و به گونه‌ای حکم کرد که نه این پیروزِ مطلق باشد و نه آن؛ عدالت را توازن دیدند نه جانبداری.",
"ru": f"Двое соседей спорили из‑за стены. {name_ru} измерил участок, выслушал обе стороны и вынес решение, "
f"где ни один не выиграл полностью и не проиграл. Так они увидели справедливость как равновесие, а не пристрастие.",
},
})
sections.append({
"title": {
"en": f"Anecdote: A Miraculous Sign with {name_en}",
"fa": f"حکایت: نشانه‌ای شگفت با {name_fa}",
"ru": f"История: Чудесный знак с {name_ru}",
},
"text": {
"en": f"In a moment of fear, a small sign appeared—unexpected help arrived at the right time. "
f"People said, 'It was a mercy,' and {name_en} reminded them that signs awaken gratitude and responsibility.",
"fa": f"در لحظه‌ای هراس‌انگیز، نشانه‌ای پدیدار شد؛ یاریِ ناگهانی در زمانِ درست. "
f"مردم گفتند: «رحمتی بود»، و {name_fa} یادآور شد که نشانه‌ها سپاس و مسئولیت می‌آموزند.",
"ru": f"В миг страха явился маленький знак — помощь пришла вовремя. "
f"Люди сказали: «Это была милость», а {name_ru} напомнил, что знамения пробуждают благодарность и ответственность.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Teaching with Gentle Words of {name_en}",
"fa": f"حکایت: تعلیم با سخن نرم از {name_fa}",
"ru": f"История: Наставление мягким словом от {name_ru}",
},
"text": {
"en": f"A young student erred while reading. {name_en} corrected without humiliation, "
f"explaining with care until understanding bloomed. Knowledge, they said, enters where hearts feel safe.",
"fa": f"شاگردی در خواندن خطا کرد. {name_fa} بی‌آنکه او را خوار کند، با دلسوزی توضیح داد تا فهم شکوفا شد. "
f"گفت: دانش، جایی وارد می‌شود که دل‌ها امن باشند.",
"ru": f"Юный ученик ошибся в чтении. {name_ru} исправил без унижения и терпеливо объяснил, пока не пришло понимание. "
f"Знание входит туда, где сердце в безопасности.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Night Prayer and Humility of {name_en}",
"fa": f"حکایت: نماز شب و فروتنی {name_fa}",
"ru": f"История: Ночная молитва и смирение {name_ru}",
},
"text": {
"en": f"In the stillness of the night, {name_en} stood in prayer, whispering gratitude and seeking guidance. "
f"Those who saw learned that inner strength is born from humble devotion.",
"fa": f"در سکوت شب، {name_fa} به نماز ایستاد؛ شکر می‌گفت و راه می‌جست. "
f"بینندگان آموختند که قوت درون از بندگی فروتنانه زاده می‌شود.",
"ru": f"В тишине ночи {name_ru} стоял в молитве, шепча благодарность и прося наставления. "
f"Те, кто видел, поняли: внутренняя сила рождается из смиренного поклонения.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Generosity Without Expectation by {name_en}",
"fa": f"حکایت: بخشش بی‌منت از {name_fa}",
"ru": f"История: Щедрость без ожиданий от {name_ru}",
},
"text": {
"en": f"A poor family hid their need out of modesty. {name_en} discreetly sent provisions for days, "
f"asking no thanks. True giving, they taught, seeks no witness but the All‑Seeing.",
"fa": f"خانواده‌ای نیاز خود را از شرم پنهان می‌کردند. {name_fa} بی‌صدا آذوقهٔ چند روزشان را رساند "
f"و هیچ سپاسی نخواست؛ آموخت که بخششِ راستین، جز دیدهٔ حق گواهی نمی‌طلبد.",
"ru": f"Бедная семья скрывала нужду из скромности. {name_ru} тайно прислал им припасы на несколько дней "
f"и не просил благодарности. Истинная щедрость не ищет свидетелей, кроме Всевидящего.",
},
})
sections.append({
"title": {
"en": f"Anecdote: Legacy That Inspires of {name_en}",
"fa": f"حکایت: میراث الهام‌بخشِ {name_fa}",
"ru": f"История: Наследие, которое вдохновляет {name_ru}",
},
"text": {
"en": f"Years later, children repeated the sayings of {name_en} and neighbors kept the customs of mercy, justice, and truth. "
f"The legacy was not stone or gold, but transformed hearts.",
"fa": f"سال‌ها بعد، کودکان سخنانِ {name_fa} را بازمی‌گفتند و همسایگان آیینِ رحمت، عدالت و راستی را نگه می‌داشتند. "
f"میراث، سنگ و زر نبود؛ دل‌های دگرگون‌شده بود.",
"ru": f"Спустя годы дети повторяли изречения {name_ru}, а соседи хранили обычаи милости, справедливости и истины. "
f"Их наследие было не в камне и золоте, а в преображенных сердцах.",
},
})
return sections
class Command(BaseCommand):
help = "Seed 20 blogs with 10 related contents each in fa, en, ru languages. Images are randomly assigned from seeds/images."
def add_arguments(self, parser):
parser.add_argument("--blogs", type=int, default=20, help="Number of blogs to create")
parser.add_argument("--contents", type=int, default=10, help="Number of contents per blog")
parser.add_argument("--commit", action="store_true", help="Persist changes to the database. If omitted, runs in dry-run mode.")
parser.add_argument("--images-dir", type=str, default="", help="Override images directory (defaults to BASE_DIR/seeds/images)")
def handle(self, *args, **options):
blogs_count = int(options.get("blogs") or 20)
contents_count = int(options.get("contents") or 10)
commit = bool(options.get("commit"))
images_dir_opt = options.get("images_dir")
# Load image candidates
images = []
if images_dir_opt:
base = images_dir_opt
if os.path.isdir(base):
for name in os.listdir(base):
lower = name.lower()
if lower.endswith((".jpg", ".jpeg", ".png", ".webp")):
images.append(os.path.join(base, name))
else:
images = get_seed_images()
if not images:
self.stdout.write(self.style.WARNING("No seed images found under seeds/images/. Thumbnails and content images will be empty."))
topics = generate_topics()
if blogs_count > len(topics):
blogs_count = len(topics)
created_blogs = 0
created_contents = 0
for idx in range(blogs_count):
topic = topics[idx]
name_en = topic["en"]
name_fa = topic["fa"]
name_ru = topic["ru"]
title_values = {"en": f"Biography: {name_en}", "fa": f"زندگی‌نامه: {name_fa}", "ru": f"Биография: {name_ru}"}
slogan_values = {"en": f"Stories and lessons from {name_en}", "fa": f"حکایت‌ها و درس‌ها از {name_fa}", "ru": f"Истории и уроки о {name_ru}"}
summary_values = {
"en": f"A curated collection of chapters about {name_en}, covering life, teachings, and legacy.",
"fa": f"مجموعه‌ای منتخب از فصل‌ها درباره {name_fa} شامل زندگی، تعالیم و میراث.",
"ru": f"Подборка глав о {name_ru}, охватывающих жизнь, учение и наследие.",
}
blog = Blog(
title=build_multilang_list(title_values, "title"),
slogan=build_multilang_list(slogan_values, "title"),
summary=build_multilang_list(summary_values, "text"),
)
# Assign a random thumbnail image if available
thumb_path = pick_image_path(images)
if thumb_path:
ext = os.path.splitext(thumb_path)[1].lower()
fname = f"seed_thumb_{uuid.uuid4().hex}{ext}"
if commit:
with open(thumb_path, "rb") as f:
blog.thumbnail.save(fname, File(f), save=False)
else:
# Dry-run: simulate
blog.thumbnail.name = os.path.join("blog/thumbnails", fname)
self.stdout.write(f"[{'COMMIT' if commit else 'DRY'}] Preparing blog {idx+1}: {name_en}")
contents_payload = content_sections(name_en, name_fa, name_ru)
# Limit to requested count
contents_payload = contents_payload[:contents_count]
if commit:
blog.save()
created_blogs += 1
# Create related contents
order = 1
for section in contents_payload:
title_list = build_multilang_list(section["title"], "title")
text_list = build_multilang_list(section["text"], "text")
content_image_path = pick_image_path(images)
bc = BlogContent(
blog=blog,
title=title_list,
content=text_list,
slug=title_list, # allow slug generation from multilingual titles
order=order,
)
order += 1
if content_image_path:
ext = os.path.splitext(content_image_path)[1].lower()
fname = f"seed_content_{uuid.uuid4().hex}{ext}"
if commit:
with open(content_image_path, "rb") as f:
bc.image.save(fname, File(f), save=False)
else:
bc.image = None # do not assign filesystem in dry-run
if commit:
bc.save()
created_contents += 1
self.stdout.write(self.style.SUCCESS(f"Prepared {len(contents_payload)} contents for blog '{name_en}'"))
mode = "COMMIT" if commit else "DRY-RUN"
self.stdout.write(self.style.SUCCESS(f"{mode} finished. Blogs prepared: {created_blogs}, Contents prepared: {created_contents}"))
if not commit:
self.stdout.write(self.style.WARNING("Run again with --commit to persist the changes."))

238
apps/blog/migrations/0001_initial.py

@ -1,238 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-22 10:48
import dj_language.field
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("dj_language", "0002_auto_20220120_1344"),
]
operations = [
migrations.CreateModel(
name="Blog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"title",
models.JSONField(
blank=True, default=list, null=True, verbose_name="title"
),
),
(
"thumbnail",
models.ImageField(
help_text="Blog thumbnail image",
upload_to="blog/thumbnails/%Y/%m/",
verbose_name="Thumbnail",
),
),
(
"slogan",
models.JSONField(
blank=True, default=list, null=True, verbose_name="slogan"
),
),
(
"summary",
models.JSONField(
blank=True, default=list, null=True, verbose_name="summary"
),
),
(
"views_count",
models.PositiveIntegerField(
default=0,
help_text="Number of times this blog was viewed",
verbose_name="Views Count",
),
),
(
"slug",
models.JSONField(
blank=True,
default=list,
help_text="URL slug for the blog",
null=True,
verbose_name="slug",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Updated At"),
),
],
options={
"verbose_name": "Blog",
"verbose_name_plural": "Blogs",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="BlogSeo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"title",
models.CharField(
blank=True,
help_text="maximum length of page title is 70 characters and minimum length is 30",
max_length=140,
null=True,
verbose_name="seo title",
),
),
(
"keywords",
models.CharField(
blank=True,
help_text="keywords in the content that make it possible for people to find the site via search engines",
max_length=700,
null=True,
),
),
(
"description",
models.CharField(
blank=True,
help_text="describes and summarizes the contents of the page for the benefit of users and search engines",
max_length=170,
null=True,
),
),
(
"blog",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="seos",
to="blog.blog",
verbose_name="blog",
),
),
(
"language",
dj_language.field.LanguageField(
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",
),
),
],
options={
"verbose_name": "Blog SEO",
"verbose_name_plural": "Blog SEOs",
},
),
migrations.CreateModel(
name="BlogContent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"title",
models.JSONField(
blank=True,
default=list,
help_text="Title of this content section",
null=True,
verbose_name="Content title",
),
),
(
"content",
models.JSONField(
blank=True,
default=list,
help_text="The main content text",
null=True,
verbose_name="content",
),
),
(
"slug",
models.JSONField(
blank=True,
default=list,
help_text="URL slug for this content (optional)",
null=True,
verbose_name="slug",
),
),
(
"image",
models.ImageField(
blank=True,
help_text="Optional image for this content section",
null=True,
upload_to="blog/content_images/%Y/%m/",
verbose_name="Image",
),
),
(
"order",
models.PositiveIntegerField(
default=0,
help_text="Order of this content within the blog",
verbose_name="Order",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Updated At"),
),
(
"blog",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="contents",
to="blog.blog",
verbose_name="Blog",
),
),
],
options={
"verbose_name": "Blog Content",
"verbose_name_plural": "Blog Contents",
"ordering": ["order", "created_at"],
},
),
]

23
apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py

@ -1,23 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-26 11:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='blog',
name='slogan',
field=models.JSONField(default=list, verbose_name='slogan'),
),
migrations.AlterField(
model_name='blog',
name='title',
field=models.JSONField(default=list, verbose_name='title'),
),
]

76
apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py

@ -1,76 +0,0 @@
# Generated by Django 5.2.12 on 2026-05-03 14:09
import dj_language.field
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_alter_blog_slogan_alter_blog_title'),
('dj_language', '0002_auto_20220120_1344'),
]
operations = [
migrations.AlterField(
model_name='blog',
name='slogan',
field=models.JSONField(default=list, verbose_name='Slogan'),
),
migrations.AlterField(
model_name='blog',
name='slug',
field=models.JSONField(blank=True, default=list, help_text='URL slug for the blog', null=True, verbose_name='Slug'),
),
migrations.AlterField(
model_name='blog',
name='summary',
field=models.JSONField(blank=True, default=list, null=True, verbose_name='Summary'),
),
migrations.AlterField(
model_name='blog',
name='title',
field=models.JSONField(default=list, verbose_name='Title'),
),
migrations.AlterField(
model_name='blogcontent',
name='content',
field=models.JSONField(blank=True, default=list, help_text='The main content text', null=True, verbose_name='Content'),
),
migrations.AlterField(
model_name='blogcontent',
name='slug',
field=models.JSONField(blank=True, default=list, help_text='URL slug for this content (optional)', null=True, verbose_name='Slug'),
),
migrations.AlterField(
model_name='blogcontent',
name='title',
field=models.JSONField(blank=True, default=list, help_text='Title of this content section', null=True, verbose_name='Content Title'),
),
migrations.AlterField(
model_name='blogseo',
name='blog',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seos', to='blog.blog', verbose_name='Blog'),
),
migrations.AlterField(
model_name='blogseo',
name='description',
field=models.CharField(blank=True, help_text='describes and summarizes the contents of the page for the benefit of users and search engines', max_length=170, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='blogseo',
name='keywords',
field=models.CharField(blank=True, help_text='keywords in the content that make it possible for people to find the site via search engines', max_length=700, null=True, verbose_name='Keywords'),
),
migrations.AlterField(
model_name='blogseo',
name='language',
field=dj_language.field.LanguageField(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'),
),
migrations.AlterField(
model_name='blogseo',
name='title',
field=models.CharField(blank=True, help_text='maximum length of page title is 70 characters and minimum length is 30', max_length=140, null=True, verbose_name='SEO Title'),
),
]

0
apps/blog/migrations/__init__.py

208
apps/blog/models.py

@ -1,208 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from utils import generate_slug_for_model, generate_language_slugs
from dj_language.models import Language
from unfold.contrib.forms.widgets import ArrayWidget
from dj_language.field import LanguageField
class Blog(models.Model):
"""
Blog model with title, thumbnail, slogan, summary, views count and timestamps
"""
title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Title')) # [{"title": "", "language_code": "en"},{"title": "", "language_code": "fa"},...]
thumbnail = models.ImageField(
upload_to='blog/thumbnails/%Y/%m/',
verbose_name=_('Thumbnail'),
help_text=_('Blog thumbnail image')
)
slogan = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Slogan'))
summary = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Summary'))
views_count = models.PositiveIntegerField(
default=0,
verbose_name=_('Views Count'),
help_text=_('Number of times this blog was viewed')
)
slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'), help_text=_('URL slug for the blog'))
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_('Updated At')
)
class Meta:
ordering = ['-created_at']
verbose_name = _('Blog')
verbose_name_plural = _('Blogs')
def __str__(self):
text = self._extract_text_from_json(self.title)
if text:
return text
return f"Blog #{self.pk}" if self.pk else "Blog"
@staticmethod
def _extract_text_from_json(value):
if not value:
return ""
# cases: list of dicts, list of strings, dict mapping, plain string
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
text = item.get('title') or item.get('value') or item.get('text')
if text:
return str(text)
else:
if item:
return str(item)
return ""
if isinstance(value, dict):
# Prefer common language codes if present
for lang in ("fa", "en", "ru"):
if lang in value and value[lang]:
v = value[lang]
if isinstance(v, dict):
return str(v.get('title') or v.get('value') or v.get('text') or "")
return str(v)
# Fallback to first non-empty value
for v in value.values():
if isinstance(v, dict):
txt = v.get('title') or v.get('value') or v.get('text')
if txt:
return str(txt)
elif v:
return str(v)
return ""
if isinstance(value, (str, int, float)):
return str(value)
return ""
def increment_view_count(self):
"""Increment the view count by 1"""
self.views_count += 1
self.save(update_fields=['views_count'])
return self.views_count
def get_seo_for_language(self, language_code):
try:
seo_field_object = self.seos.filter(language__code=language_code).first()
if seo_field_object:
return {
"title": seo_field_object.title,
"description": seo_field_object.description,
}
return None
except Exception:
return None
def get_blog_filed(self, lang, blog_field):
try:
if isinstance(blog_field, list) and blog_field:
for tr in blog_field:
if isinstance(tr, dict) and tr.get('language_code') == lang:
return tr.get('title') or tr.get('text') or tr.get('value')
return None
except Exception as exp:
print(f'---> Error in get_blog_filed: {exp}')
return None
def save(self, *args, **kwargs):
try:
self.slug = generate_language_slugs(self.title)
except Exception:
self.slug = []
super().save(*args, **kwargs)
class BlogContent(models.Model):
"""
BlogContent model related to Blog with title, content, slug, image, order and timestamps
"""
blog = models.ForeignKey(
Blog,
on_delete=models.CASCADE,
related_name='contents',
verbose_name=_('Blog')
)
title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Content Title'), help_text=_('Title of this content section'))
content = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Content'), help_text=_('The main content text'))
slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'), help_text=_('URL slug for this content (optional)'))
image = models.ImageField(
upload_to='blog/content_images/%Y/%m/',
null=True,
blank=True,
verbose_name=_('Image'),
help_text=_('Optional image for this content section')
)
order = models.PositiveIntegerField(
default=0,
verbose_name=_('Order'),
help_text=_('Order of this content within the blog')
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created At')
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_('Updated At')
)
class Meta:
ordering = ['order', 'created_at']
verbose_name = _('Blog Content')
verbose_name_plural = _('Blog Contents')
# unique_together = ['blog', 'order']
def __str__(self):
title_text = Blog._extract_text_from_json(self.title)
if title_text:
return title_text
blog_text = Blog._extract_text_from_json(self.blog.title) if self.blog_id else "Blog"
return f"{blog_text} - Content #{self.pk or ''}".strip()
def save(self, *args, **kwargs):
try:
self.slug = generate_language_slugs(self.slug)
except Exception:
pass
super().save(*args, **kwargs)
class BlogSeo(models.Model):
blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='seos', verbose_name=_('Blog'))
title = models.CharField(
_('SEO Title'), max_length=140, null=True, blank=True,
help_text=_('maximum length of page title is 70 characters and minimum length is 30'),
)
keywords = models.CharField(
_('Keywords'),
max_length=700, null=True, blank=True,
help_text=_('keywords in the content that make it possible for people to find the site via search engines')
)
description = models.CharField(
_('Description'),
max_length=170, null=True, blank=True,
help_text=_('describes and summarizes the contents of the page for the benefit of users and search engines'),
)
language = LanguageField(null=True, verbose_name=_('Language'))
class Meta:
verbose_name = _('Blog SEO')
verbose_name_plural = _('Blog SEOs')
def __str__(self):
lang = getattr(self.language, 'code', None) if self.language else None
return f"SEO({lang or '-'}) - {self.title or ''}"

142
apps/blog/serializers.py

@ -1,142 +0,0 @@
from rest_framework import serializers
from utils import FileFieldSerializer
from .models import Blog, BlogContent
class BlogContentSerializer(serializers.ModelSerializer):
"""
Serializer for BlogContent model with all details
"""
image = FileFieldSerializer(required=False, allow_null=True)
title = serializers.SerializerMethodField()
content = serializers.SerializerMethodField()
slug = serializers.SerializerMethodField()
class Meta:
model = BlogContent
fields = [
'id',
'title',
'content',
'slug',
'image',
'order',
'created_at',
'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def _lang(self):
request = self.context.get('request')
return getattr(request, 'LANGUAGE_CODE', None) or 'en'
def get_title(self, obj: BlogContent):
return obj.blog.get_blog_filed(self._lang(), obj.title)
def get_content(self, obj: BlogContent):
return obj.blog.get_blog_filed(self._lang(), obj.content)
def get_slug(self, obj: BlogContent):
return obj.blog.get_blog_filed(self._lang(), obj.slug)
class BlogListSerializer(serializers.ModelSerializer):
"""
Serializer for Blog list view with file field for thumbnail
"""
thumbnail = FileFieldSerializer(required=False)
title = serializers.SerializerMethodField()
slogan = serializers.SerializerMethodField()
summary = serializers.SerializerMethodField()
slug = serializers.SerializerMethodField()
seo = serializers.SerializerMethodField()
class Meta:
model = Blog
fields = [
'id',
'title',
'thumbnail',
'slogan',
'summary',
'views_count',
'slug',
'seo',
'created_at',
'updated_at'
]
read_only_fields = ['id', 'views_count', 'created_at', 'updated_at']
def _lang(self):
request = self.context.get('request')
return getattr(request, 'LANGUAGE_CODE', None) or 'en'
def get_title(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.title)
def get_slogan(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.slogan)
def get_summary(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.summary)
def get_slug(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.slug)
def get_seo(self, obj: Blog):
return obj.get_seo_for_language(self._lang())
class BlogDetailSerializer(serializers.ModelSerializer):
"""
Serializer for Blog detail view with related BlogContent
"""
thumbnail = FileFieldSerializer(required=False)
contents = serializers.SerializerMethodField()
title = serializers.SerializerMethodField()
slogan = serializers.SerializerMethodField()
summary = serializers.SerializerMethodField()
slug = serializers.SerializerMethodField()
seo = serializers.SerializerMethodField()
class Meta:
model = Blog
fields = [
'id',
'title',
'thumbnail',
'slogan',
'summary',
'views_count',
'slug',
'seo',
'created_at',
'updated_at',
'contents'
]
def get_contents(self, obj: Blog):
# Pass down context (request) to nested serializer
ser = BlogContentSerializer(obj.contents.all().order_by('order'), many=True, context=self.context)
return ser.data
read_only_fields = ['id', 'views_count', 'created_at', 'updated_at']
def _lang(self):
request = self.context.get('request')
return getattr(request, 'LANGUAGE_CODE', None) or 'en'
def get_title(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.title)
def get_slogan(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.slogan)
def get_summary(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.summary)
def get_slug(self, obj: Blog):
return obj.get_blog_filed(self._lang(), obj.slug)
def get_seo(self, obj: Blog):
return obj.get_seo_for_language(self._lang())

3
apps/blog/tests.py

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

24
apps/blog/urls.py

@ -1,24 +0,0 @@
from django.urls import path, re_path
from .views import BlogListAPIView, RelatedBlogsAPIView, BlogDetailBySlugAPIView
app_name = 'blog'
urlpatterns = [
# Blog list with search and sort_by filters
path('list/', BlogListAPIView.as_view(), name='blog-list'),
# Related blogs for a specific blog ID
path('related/<int:blog_id>/', RelatedBlogsAPIView.as_view(), name='related-blogs'),
# Blog detail by slug (using regex to support different languages)
re_path(r'^detail/(?P<slug>[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$',
BlogDetailBySlugAPIView.as_view(),
name='blog-detail'),
]

183
apps/blog/views.py

@ -1,183 +0,0 @@
from rest_framework.generics import ListAPIView, GenericAPIView
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from django.shortcuts import get_object_or_404
from .models import Blog
from .serializers import BlogListSerializer, BlogDetailSerializer
import random
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from utils.pagination import StandardResultsSetPagination
class BlogListAPIView(ListAPIView):
"""
API view to list blogs with search and sort_by filters
"""
serializer_class = BlogListSerializer
permission_classes = [AllowAny]
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description="List blogs with optional search and sort_by filters",
tags=["Imam-Javad - Blog"],
manual_parameters=[
openapi.Parameter(
name='search',
in_=openapi.IN_QUERY,
description='Search in title, slogan, or summary',
type=openapi.TYPE_STRING,
required=False
),
openapi.Parameter(
name='sort_by',
in_=openapi.IN_QUERY,
description="Sorting: 'latest' or 'most_viewed'",
type=openapi.TYPE_STRING,
required=False
),
],
responses={
200: openapi.Response(
description="List of blogs",
schema=BlogListSerializer(many=True)
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
queryset = Blog.objects.all()
# Search filter
search = self.request.query_params.get('search', None)
if search:
queryset = queryset.filter(
Q(title__icontains=search) |
Q(slogan__icontains=search) |
Q(summary__icontains=search)
)
# Sort by filter
sort_by = self.request.query_params.get('sort_by', None)
if sort_by == 'latest':
queryset = queryset.order_by('-created_at')
elif sort_by == 'most_viewed':
queryset = queryset.order_by('-views_count')
else:
# Default ordering
queryset = queryset.order_by('-created_at')
return queryset
class RelatedBlogsAPIView(GenericAPIView):
"""
API view to get 10 random related blogs for a given blog ID
"""
serializer_class = BlogListSerializer
permission_classes = [AllowAny]
@swagger_auto_schema(
operation_description="Get up to 10 random related blogs for the given blog_id",
tags=["Imam-Javad - Blog"],
manual_parameters=[
openapi.Parameter(
name='blog_id',
in_=openapi.IN_PATH,
description='Current blog ID to exclude',
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={
200: openapi.Response(
description="Related blogs",
schema=BlogListSerializer(many=True)
)
}
)
def get(self, request, blog_id):
"""
Get 10 random blogs excluding the current blog
"""
try:
# Get the current blog to exclude it from results
current_blog = get_object_or_404(Blog, id=blog_id)
# Get all blogs except the current one
all_blogs = list(Blog.objects.exclude(id=blog_id))
# Get random 10 blogs (or less if there are fewer blogs)
random_count = min(10, len(all_blogs))
if random_count > 0:
related_blogs = random.sample(all_blogs, random_count)
else:
related_blogs = []
serializer = self.get_serializer(related_blogs, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{'error': 'Blog not found or error occurred'},
status=status.HTTP_404_NOT_FOUND
)
class BlogDetailBySlugAPIView(GenericAPIView):
"""
API view to get blog details by slug and increment view count
"""
serializer_class = BlogDetailSerializer
permission_classes = [AllowAny]
@swagger_auto_schema(
operation_description="Get blog details by slug and increment view count",
tags=["Imam-Javad - Blog"],
manual_parameters=[
openapi.Parameter(
name='slug',
in_=openapi.IN_PATH,
description='Blog slug',
type=openapi.TYPE_STRING,
required=True
)
],
responses={
200: openapi.Response(
description="Blog detail",
schema=BlogDetailSerializer()
)
}
)
def get(self, request, slug):
"""
Get blog details by slug and increment view count
"""
try:
# Slug is stored as list of objects in JSONField -> filter accordingly
blog = Blog.objects.filter(slug__contains=[{'title': slug}]).first()
if not blog:
return Response({'error': 'Blog not found'}, status=status.HTTP_404_NOT_FOUND)
# Increment view count
blog.increment_view_count()
# Get related blog contents ordered by order field
blog_with_contents = Blog.objects.prefetch_related(
'contents'
).get(id=blog.id)
serializer = self.get_serializer(blog_with_contents, context={'request': request})
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{'error': 'Blog not found'},
status=status.HTTP_404_NOT_FOUND
)

63
apps/bookmark/tests.py

@ -1,3 +1,62 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APITestCase
# Create your tests here.
class BookmarkURLResolutionTests(APITestCase):
"""
Test suite to ensure all bookmark and rate API endpoints resolve and execute cleanly.
"""
def test_add_bookmark_endpoint(self):
"""Test add bookmark endpoint is accessible (POST)"""
url = reverse('bookmark:add_bookmark')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_add_bookmark_list_endpoint(self):
"""Test add bookmark list endpoint is accessible (POST)"""
url = reverse('bookmark:add_bookmark_list')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_remove_bookmark_endpoint(self):
"""Test remove bookmark endpoint is accessible (POST)"""
url = reverse('bookmark:remove_bookmark')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_remove_bookmark_list_endpoint(self):
"""Test remove bookmark list endpoint is accessible (POST)"""
url = reverse('bookmark:remove_bookmark_list')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_bookmark_status_endpoint(self):
"""Test bookmark status endpoint is accessible"""
url = reverse('bookmark:bookmark_status')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_add_rate_endpoint(self):
"""Test add rate endpoint is accessible (POST)"""
url = reverse('bookmark:add_rate')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_remove_rate_endpoint(self):
"""Test remove rate endpoint is accessible (POST)"""
url = reverse('bookmark:remove_rate')
response = self.client.post(url)
self.assertLess(response.status_code, 500)
def test_rate_status_endpoint(self):
"""Test rate status endpoint is accessible"""
url = reverse('bookmark:rate_status')
response = self.client.get(url)
self.assertLess(response.status_code, 500)
def test_average_rate_endpoint(self):
"""Test average rate endpoint is accessible"""
url = reverse('bookmark:average_rate')
response = self.client.get(url)
self.assertLess(response.status_code, 500)

0
apps/certificate/__init__.py

45
apps/certificate/admin.py

@ -1,45 +0,0 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.utils.html import format_html
from unfold.admin import ModelAdmin
from unfold.decorators import display
from apps.certificate.models import Certificate
from utils.admin import project_admin_site
from apps.course.admin.professor_base import CertificateBaseAdmin
@admin.register(Certificate)
class CertificateAdmin(CertificateBaseAdmin):
list_display = ['student', 'course', 'certificate_status', 'created_at']
list_filter = ['status', 'created_at']
search_fields = ['id', 'student__username', 'student__email', 'course__title']
readonly_fields = ['created_at', 'updated_at']
autocomplete_fields = ['student',]
fieldsets = (
(None, {
'fields': ('student', 'course', 'status', 'certificate_file')
}),
(_('Timestamps'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
@display(description=_("Status"), ordering="status")
def certificate_status(self, obj):
status_classes = {
'pending': 'unfold-badge unfold-badge--warning',
'approved': 'unfold-badge unfold-badge--success',
'rejected': 'unfold-badge unfold-badge--danger',
'issued': 'unfold-badge unfold-badge--info',
}
status_class = status_classes.get(obj.status.lower(), 'unfold-badge')
return format_html('<span class="{}">{}</span>', status_class, obj.get_status_display())
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset
project_admin_site.register(Certificate, CertificateAdmin)

6
apps/certificate/apps.py

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

69
apps/certificate/migrations/0001_initial.py

@ -1,69 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-22 10:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("course", "0001_initial"),
("account", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Certificate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"status",
models.CharField(
choices=[
("pending", "pending"),
("approved", "approved"),
("canceled", "canceled"),
],
default="pending",
max_length=10,
),
),
(
"certificate_file",
models.FileField(
blank=True,
null=True,
upload_to="certificates/",
verbose_name="certificate_file",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"course",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="course_certificates",
to="course.course",
),
),
(
"student",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="certificates",
to="account.studentuser",
),
),
],
),
]

36
apps/certificate/migrations/0002_alter_certificate_course_and_more.py

@ -1,36 +0,0 @@
# Generated by Django 5.2.12 on 2026-05-04 12:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0003_alter_clientuser_options_and_more'),
('certificate', '0001_initial'),
('course', '0006_alter_course_professor_alter_course_video_file_and_more'),
]
operations = [
migrations.AlterField(
model_name='certificate',
name='course',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_certificates', to='course.course', verbose_name='Course'),
),
migrations.AlterField(
model_name='certificate',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='certificate',
name='status',
field=models.CharField(choices=[('pending', 'pending'), ('approved', 'approved'), ('canceled', 'canceled')], default='pending', max_length=10, verbose_name='Status'),
),
migrations.AlterField(
model_name='certificate',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='account.studentuser', verbose_name='Student'),
),
]

0
apps/certificate/migrations/__init__.py

28
apps/certificate/models.py

@ -1,28 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from filer.fields.file import FilerFileField
from apps.course.models import Course
from apps.account.models import StudentUser
class Certificate(models.Model):
STATUS_CHOICES = [
('pending', _('pending')),
('approved', _('approved')),
('canceled', _('canceled')),
]
student = models.ForeignKey(StudentUser, on_delete=models.CASCADE, related_name='certificates', verbose_name=_('Student'))
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_certificates', verbose_name=_('Course'))
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending', verbose_name=_('Status'))
certificate_file = models.FileField(upload_to='certificates/', null=True, blank=True, verbose_name=_('certificate_file'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Certificate {self.student.fullname} - {self.course.title}"

50
apps/certificate/serializers.py

@ -1,50 +0,0 @@
from rest_framework import serializers
from apps.certificate.models import Certificate
from apps.course.serializers import CourseDetailSerializer
from django.conf import settings
class CertificateSerializer(serializers.ModelSerializer):
course = serializers.SerializerMethodField()
certificate_file = serializers.SerializerMethodField()
class Meta:
model = Certificate
fields = ['id', 'student', 'course', 'status', 'created_at', 'updated_at', 'certificate_file']
read_only_fields = ['id', 'student', 'status', 'created_at', 'updated_at',]
def get_course(self, obj):
return CourseDetailSerializer(obj.course, context=self.context).data
def get_certificate_file(self, obj):
if obj.certificate_file:
request = self.context.get('request')
if request is not None:
return request.build_absolute_uri(obj.certificate_file.url)
return obj.certificate_file.url
return None
class CertificateRequestSerializer(serializers.ModelSerializer):
class Meta:
model = Certificate
fields = ['id', 'course']
read_only_fields = ['id']
def create(self, validated_data):
user = self.context['request'].user
course = validated_data['course']
if Certificate.objects.filter(student=user, course=course, status__in=['pending', 'approved']).exists():
raise serializers.ValidationError({
"course": "A certificate request for this course is already pending or approved."
})
return Certificate.objects.create(student=user, course=course)

3
apps/certificate/tests.py

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

11
apps/certificate/urls.py

@ -1,11 +0,0 @@
from django.urls import path
from .views import CertificateRequestView, UserCertificatesListView
urlpatterns = [
path('request/', CertificateRequestView.as_view(), name='certificate-request'),
path('my-certificates/', UserCertificatesListView.as_view(), name='user-certificates'),
]

56
apps/certificate/views.py

@ -1,56 +0,0 @@
from rest_framework import generics, permissions
from rest_framework.authentication import TokenAuthentication
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from apps.certificate.models import Certificate
from apps.certificate.serializers import CertificateRequestSerializer, CertificateSerializer
from utils.pagination import StandardResultsSetPagination
class CertificateRequestView(generics.CreateAPIView):
queryset = Certificate.objects.all()
serializer_class = CertificateRequestSerializer
permission_classes = [permissions.IsAuthenticated]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description="Request a certificate for completed course",
tags=["Imam-Javad - Certificate"],
responses={
201: openapi.Response(
description="Certificate request created successfully"
)
}
)
def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)
def perform_create(self, serializer):
serializer.save(student=self.request.user)
class UserCertificatesListView(generics.ListAPIView):
serializer_class = CertificateSerializer
permission_classes = [permissions.IsAuthenticated]
authentication_classes = [TokenAuthentication]
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description="Get list of user's certificates",
tags=["Imam-Javad - Certificate"],
responses={
200: openapi.Response(
description="List of user certificates",
schema=CertificateSerializer(many=True)
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return Certificate.objects.filter(student=self.request.user).order_by('-created_at')

0
apps/chat/__init__.py

366
apps/chat/admin.py

@ -1,366 +0,0 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.db.models import Count
from django import forms
from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import RangeNumericFilter, RangeDateTimeFilter
from django.shortcuts import redirect
from django.urls import reverse
from unfold.decorators import action, display
from django.contrib import messages
from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
from utils.admin import project_admin_site, admin_url_generator
from django.contrib.auth import get_user_model
User = get_user_model()
# --- HELPER FUNCTION: GET ALLOWED USERS FOR A ROOM ---
def get_allowed_users_for_room(room):
"""
Returns a queryset of active users allowed in a specific room.
Private: Initiator + Recipient
Group: Initiator + Recipient + Course Professor + Active Course Participants
"""
allowed_ids = set()
if room.initiator_id:
allowed_ids.add(room.initiator_id)
if room.recipient_id:
allowed_ids.add(room.recipient_id)
if room.room_type == 'group' and room.course_id:
# Add Professor
if room.course.professor_id:
allowed_ids.add(room.course.professor_id)
# Add Active Participants
from apps.course.models import Participant
participant_ids = Participant.objects.filter(
course_id=room.course_id,
is_active=True
).values_list('student_id', flat=True)
allowed_ids.update(participant_ids)
return User.objects.filter(is_active=True, id__in=allowed_ids)
# --- WIDTH ENFORCEMENT FOR SELECT2 DROPDOWNS IN INLINES ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
target_dropdown_fields = ['sender', 'user']
for field_name, field in self.fields.items():
if field_name in target_dropdown_fields and hasattr(field.widget, 'attrs'):
existing_class = field.widget.attrs.get('class', '')
field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
existing_style = field.widget.attrs.get('style', '')
field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
class ChatMessageInline(TabularInline):
model = ChatMessage
form = MinWidthInlineForm
extra = 1 # 🔔 Allows you to add 1 new message from the room tab
tab = True
# 🔔 Using real database fields so you can actually input new messages
fields = ('sender', 'content', 'content_type', 'file_attachment', 'sent_at', 'is_deleted')
readonly_fields = ('sent_at',)
can_delete = False
show_change_link = True
verbose_name = _("Recent Message")
verbose_name_plural = _("Recent Messages (Latest 50)")
def get_queryset(self, request):
qs = super().get_queryset(request)
object_id = request.resolver_match.kwargs.get('object_id')
if object_id:
latest_ids = list(qs.filter(room_id=object_id).order_by('-sent_at').values_list('id', flat=True)[:50])
return qs.filter(id__in=latest_ids).order_by('-sent_at')
return qs.none()
# 🔔 FILTER THE SENDER DROPDOWN IN THE TAB
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "sender":
room_id = request.resolver_match.kwargs.get('object_id')
if room_id:
try:
room = RoomMessage.objects.get(pk=room_id)
kwargs["queryset"] = get_allowed_users_for_room(room)
except RoomMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class MessageReadStatusAdmin(ModelAdmin):
list_display = (
'user', 'message', 'is_read_status', 'read_at',
)
list_filter = (
('read_at', RangeDateTimeFilter),
'is_read',
)
search_fields = ('user__username', 'user__email', 'message__content')
readonly_fields = ('read_at',)
@display(description=_("Read Status"))
def is_read_status(self, obj):
if obj.is_read:
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Read"))
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Unread"))
class RoomMessageAdmin(ModelAdmin):
list_display = (
'name', 'room_type_badge', 'course', 'initiator',
'messages_count', 'is_locked'
)
list_filter = (
'room_type',
('created_at', RangeDateTimeFilter),
('updated_at', RangeDateTimeFilter),
'course','is_locked'
)
search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username')
ordering = ('-created_at',)
readonly_fields = ('created_at', 'updated_at', 'messages_count')
inlines = [ChatMessageInline]
actions_detail = ['manage_all_messages']
fieldsets = (
(_("Room Information"), {
'fields': ('name', 'description', 'room_type', 'messages_count','is_locked'),
'classes': ('grid-col-2',),
}),
(_("Relations"), {
'fields': ('course', 'initiator', 'recipient'),
'classes': ('grid-col-2',),
}),
(_("Timestamps"), {
'fields': ('created_at', 'updated_at'),
'classes': ('grid-col-2',),
}),
)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name in ["initiator", "recipient"]:
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@display(description=_("Messages Count"))
def messages_count(self, obj):
count = obj.messages.count()
return format_html(
'<span class="bg-primary-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[2rem]">'
'{}</span>', count
)
@display(description=_("Room Type"))
def room_type_badge(self, obj):
if obj.room_type == 'group':
return format_html('<span class="bg-purple-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Group"))
return format_html('<span class="bg-indigo-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Private"))
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(
total_messages=Count('messages')
)
return queryset
@action(
description=_("Manage All Messages"),
icon="chat",
)
def manage_all_messages(self, request, object_id):
"""Redirect to the pre-filtered Chat Message changelist for this room."""
room = self.get_object(request, object_id)
if not room:
messages.error(request, _("Room not found"))
return redirect(admin_url_generator(request, "chat_roommessage_changelist"))
base_url = admin_url_generator(request, "chat_chatmessage_changelist")
url = f"{base_url}?room__id__exact={object_id}"
return redirect(url)
class MessageReadStatusInline(TabularInline):
model = MessageReadStatus
form = MinWidthInlineForm
extra = 0
fields = ('user', 'is_read', 'read_at')
readonly_fields = ('read_at',)
can_delete = False
show_change_link = True
classes = ['collapse']
verbose_name = _("Read Status")
verbose_name_plural = _("Read Statuses")
# 🔔 FILTER THE USER DROPDOWN IN THE READ STATUS TAB
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
message_id = request.resolver_match.kwargs.get('object_id')
if message_id:
try:
msg = ChatMessage.objects.get(pk=message_id)
kwargs["queryset"] = get_allowed_users_for_room(msg.room)
except ChatMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ChatMessageAdmin(ModelAdmin):
list_display = (
'id', 'room', 'sender', 'content_type_badge', 'content_preview',
'content_size_display', 'has_attachment', 'sent_at', 'is_deleted_status'
)
list_filter = (
'room',
'content_type',
'is_deleted',
('sent_at', RangeDateTimeFilter),
('updated_at', RangeDateTimeFilter),
('content_size', RangeNumericFilter)
)
search_fields = ('room__name', 'sender__username', 'content')
ordering = ('-sent_at',)
readonly_fields = ('sent_at', 'updated_at', 'content_size', 'attachment_preview')
inlines = [MessageReadStatusInline]
fieldsets = (
(_("Message Information"), {
'fields': ('room', 'sender', 'content', 'content_type'),
'classes': ('grid-col-2',),
}),
(_("Attachments"), {
'fields': ('file_attachment', 'image_attachment', 'attachment_preview'),
'classes': ('grid-col-2',),
}),
(_("Additional Info"), {
'fields': ('content_size',),
'classes': ('grid-col-1',),
}),
(_("Status"), {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('grid-col-2',),
}),
(_("Timestamps"), {
'fields': ('sent_at', 'updated_at'),
'classes': ('grid-col-2',),
}),
)
actions_list = ["back_to_chat_rooms"]
# 🔔 FILTER THE SENDER DROPDOWN IN THE CHAT MESSAGE FORM
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "sender":
message_id = request.resolver_match.kwargs.get('object_id')
if message_id:
try:
msg = ChatMessage.objects.get(pk=message_id)
kwargs["queryset"] = get_allowed_users_for_room(msg.room)
except ChatMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@action(
description=_("Back to Chat Rooms"),
icon="arrow_back",
)
def back_to_chat_rooms(self, request):
url = reverse('admin:chat_roommessage_changelist')
return redirect(url)
@display(description=_("Content Preview"))
def content_preview(self, obj):
if obj.content_type == 'text':
preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
return preview
return _("%(type)s content") % {'type': obj.get_content_type_display()}
@display(description=_("Type"))
def content_type_badge(self, obj):
badges = {
'text': ('bg-green-500', _('Text')),
'file': ('bg-green-500', _('File')),
'audio': ('bg-green-500', _('Audio')),
'image': ('bg-green-500', _('Image')),
}
bg_color, label = badges.get(obj.content_type, ('bg-gray-500', obj.content_type))
return format_html(
'<span class="{} text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[4rem]">{}</span>',
bg_color, label
)
@display(description=_("Status"))
def is_deleted_status(self, obj):
if obj.is_deleted:
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Deleted"))
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Active"))
@display(description=_("Size"))
def content_size_display(self, obj):
if obj.content_size:
if obj.content_size > 1024:
size_kb = obj.content_size / 1024
return f"{size_kb:.1f} KB"
return _("{} bytes").format(obj.content_size)
return "-"
@display(description=_("Attachment"))
def has_attachment(self, obj):
if obj.image_attachment:
return format_html('<span class="bg-blue-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📷 Image"))
elif obj.file_attachment:
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📎 File"))
elif obj.content and obj.content_type != 'text':
return format_html('<span class="bg-orange-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("🔗 Legacy"))
return "-"
@display(description=_("Attachment Preview"))
def attachment_preview(self, obj):
if obj.image_attachment:
return format_html(
'<div><strong>{}:</strong><br/>'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 10px;" />'
'<br/><a href="{}" target="_blank" style="margin-top: 10px; display: inline-block;">{}</a></div>',
_("Image"),
obj.image_attachment.url,
obj.image_attachment.url,
_("Open in new tab")
)
elif obj.file_attachment:
return format_html(
'<div><strong>{}:</strong><br/>'
'<a href="{}" target="_blank" style="margin-top: 10px; display: inline-block; padding: 8px 16px; background: #3b82f6; color: white; border-radius: 4px; text-decoration: none;">{}</a></div>',
_("File"),
obj.file_attachment.url,
_("📥 Download File")
)
elif obj.content and obj.content_type != 'text':
return format_html(
'<div><strong>{}:</strong><br/><code style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px;">{}</code></div>',
_("Legacy URL"),
obj.content
)
return "-"
project_admin_site.register(RoomMessage, RoomMessageAdmin)
project_admin_site.register(ChatMessage, ChatMessageAdmin)
project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin)

6
apps/chat/apps.py

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

1
apps/chat/management/__init__.py

@ -1 +0,0 @@

62
apps/chat/management/commands/README.md

@ -1,62 +0,0 @@
# Chat Management Commands
## clear_chat_data
این management command برای پاک کردن داده‌های چت طراحی شده است و دو حالت کاری دارد:
### حالت پیش‌فرض (محافظت از روم‌های کورس)
در این حالت:
- همه پیام‌ها (ChatMessage) حذف می‌شوند
- همه وضعیت‌های خواندن پیام (MessageReadStatus) حذف می‌شوند
- روم‌هایی که مربوط به کورس نیستند (course=null) حذف می‌شوند
- روم‌هایی که مربوط به کورس هستند حفظ می‌شوند اما پیام‌هایشان حذف می‌شود
- تعداد پیام‌های خوانده نشده روم‌های کورس صفر می‌شود
### حالت حذف کامل
در این حالت همه داده‌های چت شامل روم‌های کورس نیز حذف می‌شوند.
## استفاده
### حالت پیش‌فرض (محافظت از روم‌های کورس)
```bash
# با تأیید کاربر
python manage.py clear_chat_data
# بدون تأیید کاربر
python manage.py clear_chat_data --force
```
### حذف کامل همه داده‌ها
```bash
# با تأیید کاربر
python manage.py clear_chat_data --all-rooms
# بدون تأیید کاربر
python manage.py clear_chat_data --all-rooms --force
```
## پارامترها
- `--force`: اجرای دستور بدون درخواست تأیید از کاربر
- `--all-rooms`: حذف همه روم‌ها شامل روم‌های مربوط به کورس
## نکات مهم
1. **ایمنی**: دستور در یک transaction اجرا می‌شود تا در صورت خطا، تغییرات rollback شوند
2. **گزارش‌دهی**: دستور تعداد رکوردهای حذف شده را نمایش می‌دهد
3. **محافظت از داده‌های کورس**: در حالت پیش‌فرض، روم‌های مربوط به کورس حفظ می‌شوند
4. **بازنشانی شمارنده**: تعداد پیام‌های خوانده نشده روم‌های کورس به صفر تنظیم می‌شود
## مثال خروجی
```
Found:
- 150 messages
- 75 read statuses
- 10 total rooms (3 course rooms, 7 non-course rooms)
✓ Deleted 75 MessageReadStatus records
✓ Deleted 150 ChatMessage records
✓ Deleted 7 non-course RoomMessage records
✓ Reset unread_messages_count for 3 course rooms
Chat data clearing completed successfully!
```

1
apps/chat/management/commands/__init__.py

@ -1 +0,0 @@

79
apps/chat/management/commands/clear_chat_data.py

@ -1,79 +0,0 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
class Command(BaseCommand):
help = 'Clear chat data: all rooms, messages and read statuses, but preserve course-related rooms'
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
dest='force',
help=_('Force deletion without confirmation'),
)
parser.add_argument(
'--all-rooms',
action='store_true',
dest='all_rooms',
help=_('Delete ALL rooms including course-related rooms'),
)
def handle(self, *args, **options):
force = options['force']
all_rooms = options['all_rooms']
if not force:
if all_rooms:
confirm = input(_('This will delete ALL chat data including course rooms. Are you sure? (yes/no): '))
else:
confirm = input(_('This will delete all messages and read statuses, and non-course rooms. Course rooms will be preserved but their messages will be deleted. Are you sure? (yes/no): '))
if confirm.lower() != 'yes':
self.stdout.write(self.style.WARNING(_('Operation cancelled.')))
return
try:
with transaction.atomic():
# Count existing data
total_messages = ChatMessage.objects.count()
total_read_statuses = MessageReadStatus.objects.count()
total_rooms = RoomMessage.objects.count()
course_rooms = RoomMessage.objects.filter(course__isnull=False).count()
non_course_rooms = RoomMessage.objects.filter(course__isnull=True).count()
self.stdout.write(self.style.WARNING(f'Found:'))
self.stdout.write(f' - {total_messages} messages')
self.stdout.write(f' - {total_read_statuses} read statuses')
self.stdout.write(f' - {total_rooms} total rooms ({course_rooms} course rooms, {non_course_rooms} non-course rooms)')
# Step 1: Delete all MessageReadStatus records
deleted_read_statuses = MessageReadStatus.objects.all().delete()[0]
self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_read_statuses} MessageReadStatus records'))
# Step 2: Delete all ChatMessage records
deleted_messages = ChatMessage.objects.all().delete()[0]
self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_messages} ChatMessage records'))
# Step 3: Handle rooms based on options
if all_rooms:
# Delete ALL rooms
deleted_rooms = RoomMessage.objects.all().delete()[0]
self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_rooms} RoomMessage records (including course rooms)'))
else:
# Delete only non-course rooms (rooms without course relationship)
deleted_non_course_rooms = RoomMessage.objects.filter(course__isnull=True).delete()[0]
self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_non_course_rooms} non-course RoomMessage records'))
# Reset unread_messages_count for course rooms
course_rooms_updated = RoomMessage.objects.filter(course__isnull=False).update(unread_messages_count=0)
self.stdout.write(self.style.SUCCESS(f'✓ Reset unread_messages_count for {course_rooms_updated} course rooms'))
self.stdout.write(self.style.SUCCESS(_('Chat data clearing completed successfully!')))
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error occurred: {str(e)}'))
raise

221
apps/chat/migrations/0001_initial.py

@ -1,221 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-22 10:48
import apps.chat.models
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", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="RoomMessage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, verbose_name="Room Name")),
(
"description",
models.TextField(blank=True, null=True, verbose_name="Description"),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Updated At"),
),
(
"room_type",
models.CharField(
choices=[("group", "Group"), ("private", "Private")],
default="group",
max_length=10,
verbose_name="Room Type",
),
),
("unread_messages_count", models.IntegerField(default=0)),
(
"course",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="room_messages",
to="course.course",
verbose_name="Course",
),
),
(
"initiator",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="initiated_rooms",
to=settings.AUTH_USER_MODEL,
verbose_name="Initiator",
),
),
(
"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",
),
),
],
),
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)"
),
),
(
"file_attachment",
models.FileField(
blank=True,
help_text="For file and audio messages",
max_length=500,
null=True,
upload_to=apps.chat.models.chat_upload_path,
verbose_name="File Attachment",
),
),
(
"image_attachment",
models.ImageField(
blank=True,
help_text="For image messages",
max_length=500,
null=True,
upload_to=apps.chat.models.chat_upload_path,
verbose_name="Image Attachment",
),
),
("is_read", models.BooleanField(default=False, verbose_name="Is Read")),
("message_metadata", models.JSONField(blank=True, null=True)),
(
"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"),
),
(
"room",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="messages",
to="chat.roommessage",
verbose_name="Room",
),
),
(
"sender",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="messages_sent",
to=settings.AUTH_USER_MODEL,
verbose_name="Sender",
),
),
],
),
migrations.CreateModel(
name="MessageReadStatus",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("is_read", models.BooleanField(default=False, verbose_name="Is Read")),
(
"read_at",
models.DateTimeField(blank=True, null=True, verbose_name="Read At"),
),
(
"message",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="read_statuses",
to="chat.chatmessage",
verbose_name="Message",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="read_statuses",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"unique_together": {("user", "message")},
},
),
]

18
apps/chat/migrations/0002_roommessage_is_locked.py

@ -1,18 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-26 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='roommessage',
name='is_locked',
field=models.BooleanField(default=False, help_text='If True, only the professor and admins can send new messages.', verbose_name='Is Locked'),
),
]

35
apps/chat/migrations/0003_alter_chatmessage_options_and_more.py

@ -1,35 +0,0 @@
# Generated by Django 5.2.12 on 2026-05-03 14:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0002_roommessage_is_locked'),
]
operations = [
migrations.AlterModelOptions(
name='chatmessage',
options={'verbose_name': 'Chat Message', 'verbose_name_plural': 'Chat Messages'},
),
migrations.AlterModelOptions(
name='messagereadstatus',
options={'verbose_name': 'Message Read Status', 'verbose_name_plural': 'Message Read Statuses'},
),
migrations.AlterModelOptions(
name='roommessage',
options={'verbose_name': 'Room Message', 'verbose_name_plural': 'Room Messages'},
),
migrations.AlterField(
model_name='chatmessage',
name='message_metadata',
field=models.JSONField(blank=True, null=True, verbose_name='Message Metadata'),
),
migrations.AlterField(
model_name='roommessage',
name='unread_messages_count',
field=models.IntegerField(default=0, verbose_name='Unread Messages Count'),
),
]

0
apps/chat/migrations/__init__.py

184
apps/chat/models.py

@ -1,184 +0,0 @@
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.account.models import User
from apps.course.models import Course
def chat_upload_path(instance, filename):
"""
Generate upload path for chat attachments
Format: chat/room_{room_id}/YYYY/MM/DD/filename
"""
date = timezone.now()
return f'chat/room_{instance.room_id}/{date.year}/{date.month:02d}/{date.day:02d}/{filename}'
class RoomMessage(models.Model):
class RoomTypeChoices(models.TextChoices):
GROUP = 'group', _('Group')
PRIVATE = 'private', _('Private')
name = models.CharField(
max_length=255,
verbose_name=_("Room Name")
)
description = models.TextField(
verbose_name=_("Description"),
blank=True,
null=True
)
course = models.ForeignKey(Course, on_delete=models.CASCADE, null=True, blank=True, related_name="room_messages", verbose_name=_("Course"))
initiator = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="initiated_rooms",
verbose_name=_("Initiator")
)
recipient = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="messages_received",
verbose_name=_("Recipient"),
null=True,
blank=True
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_("Updated At")
)
is_locked = models.BooleanField(
default=False,
verbose_name=_("Is Locked"),
help_text=_("If True, only the professor and admins can send new messages.")
)
room_type = models.CharField(
max_length=10,
choices=RoomTypeChoices.choices,
default=RoomTypeChoices.GROUP,
verbose_name=_("Room Type")
)
unread_messages_count = models.IntegerField(default=0, verbose_name=_("Unread Messages Count"))
def __str__(self):
if self.room_type == self.RoomTypeChoices.GROUP:
return f"Group Room: {self.course.title if self.course else 'N/A'}"
return f"Private Room with {self.recipient}"
class Meta:
verbose_name = _("Room Message")
verbose_name_plural = _("Room Messages")
class ChatMessage(models.Model):
class ChatTypeChoices(models.TextChoices):
TEXT = 'text', _('Text')
FILE = 'file', _('File')
AUDIO = 'audio', _('Audio')
IMAGE = 'image', _('Image')
room = models.ForeignKey(
RoomMessage,
on_delete=models.CASCADE,
related_name="messages",
verbose_name=_("Room"),
)
sender = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="messages_sent",
verbose_name=_("Sender")
)
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
)
file_attachment = models.FileField(
upload_to=chat_upload_path,
blank=True,
null=True,
max_length=500,
verbose_name=_("File Attachment"),
help_text=_("For file and audio messages")
)
image_attachment = models.ImageField(
upload_to=chat_upload_path,
blank=True,
null=True,
max_length=500,
verbose_name=_("Image Attachment"),
help_text=_("For image messages")
)
is_read = models.BooleanField(default=False, verbose_name=_("Is Read"))
message_metadata = models.JSONField(blank=True, null=True, verbose_name=_("Message Metadata"))
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"))
@property
def file_url(self):
"""
Get file URL - works for both old and new messages
For backward compatibility with messages using content field
"""
if self.image_attachment:
return self.image_attachment.url
elif self.file_attachment:
return self.file_attachment.url
elif self.content and self.content_type != 'text':
# Legacy messages with URL in content field
return self.content
return None
def delete(self, *args, **kwargs):
"""Override delete to remove uploaded files"""
if self.file_attachment:
self.file_attachment.delete(save=False)
if self.image_attachment:
self.image_attachment.delete(save=False)
super().delete(*args, **kwargs)
def __str__(self):
return f"Message from {self.sender} in {self.room}"
class Meta:
verbose_name = _("Chat Message")
verbose_name_plural = _("Chat Messages")
class MessageReadStatus(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="read_statuses",
verbose_name=_("User")
)
message = models.ForeignKey(
ChatMessage,
on_delete=models.CASCADE,
related_name="read_statuses",
verbose_name=_("Message")
)
is_read = models.BooleanField(default=False, verbose_name=_("Is Read"))
read_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Read At"))
class Meta:
unique_together = ("user", "message") # جلوگیری از ثبت تکراری
verbose_name = _("Message Read Status")
verbose_name_plural = _("Message Read Statuses")
def __str__(self):
return f"User {self.user.fullname} read Message {self.message.id}: {self.is_read}"

3
apps/chat/tests.py

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

3
apps/chat/views.py

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

0
apps/course/__init__.py

4
apps/course/admin/__init__.py

@ -1,4 +0,0 @@
from .course import *
from .lesson import *
from .participant import *
from .live_session import *

569
apps/course/admin/course.py

@ -1,569 +0,0 @@
from utils.admin import admin_url_generator
import os
import hashlib
from django.contrib import admin
from django.contrib import messages
from django import forms
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.utils.html import format_html
from django.shortcuts import redirect, render
from django.urls import reverse_lazy, reverse
from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import action, display
from unfold.sections import TableSection
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
RangeDateFilter,
RangeNumericFilter,
TextFilter,
)
from unfold.widgets import UnfoldAdminSelectWidget
from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlossaryBaseAdmin
from utils.admin import project_admin_site, dovoodi_admin_site
from utils.json_editor_field import JsonEditorWidget
from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant, CourseGlossary, CourseAttachment
from apps.course.models.lesson import Lesson, CourseLesson
from apps.account.models import StudentUser, User
from apps.quiz.models import Quiz
from utils.schema import get_weekly_timing_schema, get_course_feature_schema
class CourseTableSection(TableSection):
verbose_name = _("Course Categories")
related_name = "courses"
height = 380
fields = [
"title",
"status",
"edit_link"
]
def edit_link(self, instance):
return format_html(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
instance.id
)
edit_link.short_description = _("Edit")
class CourseCategoryAdmin(ModelAdmin):
list_display = ('name', 'slug', 'course_count')
search_fields = ('name',)
list_sections = [CourseTableSection]
fieldsets = (
(None, {
'fields': ('name', 'slug')
}),
)
@display(description=_("Courses"))
def course_count(self, obj):
count = obj.courses.all().count()
return format_html('<span class="badge badge-primary">{}</span>', count)
class CourseForm(forms.ModelForm):
class Meta:
model = Course
fields = '__all__'
exclude = ('slug',)
widgets = {
'timing': JsonEditorWidget(attrs={
'schema': get_weekly_timing_schema(),
'title': _('Course Weekly Schedule'),
}),
'features': JsonEditorWidget(attrs={
'schema': get_course_feature_schema(),
'title': _('Course Features'),
}),
}
help_texts = {
'status': _('If set to inactive, the course will not be displayed.'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['short_description'].required = True
if 'thumbnail' in self.fields:
self.fields['thumbnail'].required = True
def clean(self):
cleaned_data = super().clean()
thumbnail = cleaned_data.get('thumbnail')
has_existing_thumbnail = bool(getattr(self.instance, 'thumbnail', None))
if thumbnail is False:
self.add_error('thumbnail', _('This field is required and cannot be cleared.'))
return cleaned_data
if (thumbnail is None or thumbnail == '') and not has_existing_thumbnail:
self.add_error('thumbnail', _('This field is required.'))
return cleaned_data
# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
target_dropdown_fields = ['lesson', 'attachment', 'glossary', 'student']
for field_name, field in self.fields.items():
if field_name in target_dropdown_fields:
if hasattr(field.widget, 'attrs'):
existing_class = field.widget.attrs.get('class', '')
field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
existing_style = field.widget.attrs.get('style', '')
field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
# 🔔 Add the custom placeholder text
if hasattr(field, 'empty_label'):
field.empty_label = _("Select a value")
class CourseQuizInline(TabularInline):
model = Quiz
form = MinWidthInlineForm
# فیلدهایی که می‌خواهید در لیست تب نمایش داده شوند
fields = ('title', 'lesson', 'status')
readonly_fields = ('title', 'lesson', 'status')
extra = 0
tab = True
tab_id = "quizzes_tab"
# 🎯 این خط جادویی است! یک لینک برای رفتن به صفحه دیتیل کوییز اضافه می‌کند
show_change_link = True
verbose_name = _("Quiz")
verbose_name_plural = _("Quizzes")
# ما فقط می‌خواهیم این تب نمایشی باشد، پس دسترسی اضافه/تغییر/حذف را در این تب می‌بندیم
def has_add_permission(self, request, obj):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
class CourseAttachmentInline(TabularInline):
model = CourseAttachment
form = MinWidthInlineForm
extra = 1 # Show 1 empty dropdown by default
fields = ('attachment',)
tab = True
# Removed autocomplete_fields to restore Unfold UI
verbose_name = _("Course Attachment")
verbose_name_plural = _("Course Attachments")
class CourseGlossaryInline(TabularInline):
model = CourseGlossary
form = MinWidthInlineForm
fields = ('glossary',)
extra = 1 # Show 1 empty dropdown by default
tab = True
tab_id = "glossaries_tab"
show_change_link = True
# Removed autocomplete_fields to restore Unfold UI
class CourseLessonInline(TabularInline):
model = CourseLesson
form = MinWidthInlineForm
fields = ('lesson', 'title', 'is_active', 'priority',)
extra = 1 # Show 1 empty dropdown by default
tab = True
tab_id = "lessons_tab"
show_change_link = True
ordering_field = "priority"
# Removed autocomplete_fields to restore Unfold UI
class ParticipantInline(TabularInline):
model = Participant
form = MinWidthInlineForm
fields = ('student', 'joined_date',)
readonly_fields = ('joined_date', 'student')
extra = 0 # Remains 0 because users are added via action buttons
tab = True
tab_id = "participants_tab"
verbose_name = _("Recent Participant")
verbose_name_plural = _("Recent Participants (Latest 10)")
show_change_link = True
def get_queryset(self, request):
qs = super().get_queryset(request)
object_id = request.resolver_match.kwargs.get('object_id')
if object_id:
latest_ids = list(qs.filter(course_id=object_id).order_by('-joined_date').values_list('id', flat=True)[:10])
return qs.filter(id__in=latest_ids).order_by('-joined_date')
return qs.none()
def has_add_permission(self, request, obj):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
class AddStudentForm(forms.Form):
student = forms.ModelChoiceField(
queryset=User.objects.filter(is_active=True , email__isnull=False),
label=_("Select Student"),
widget=UnfoldAdminSelectWidget,
required=True
)
class CourseAdmin(DirectCourseAdmin):
form = CourseForm
inlines = [CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, CourseQuizInline, ParticipantInline]
list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online')
list_filter = [
('status', ChoicesDropdownFilter),
('level', ChoicesDropdownFilter),
'is_online',
'is_free',
('category', MultipleRelatedDropdownFilter),
('price', RangeNumericFilter),
]
save_as = True
warn_unsaved_form = True
search_fields = ('id','title', 'description')
exclude = ('slug', )
readonly_fields = ('final_price',)
autocomplete_fields = ('category', 'professor',)
list_filter_submit = True
change_form_show_cancel_button = True
radio_fields = {
"video_type": admin.HORIZONTAL,
"status": admin.HORIZONTAL,
"level": admin.HORIZONTAL,
}
conditional_fields = {
'price': "is_free == false",
'discount_percentage': "is_free == false",
'final_price': "is_free == false",
'online_link': "is_online",
'video_file': "video_type == 'video_file'",
'video_link': "video_type == 'youtube_link'",
}
fieldsets = (
(None, {
'fields': ('title', 'category', 'professor', 'thumbnail', 'description', 'short_description')
}),
(_('Settings & Status'), {
'fields': (
('status', 'level'),
('duration', 'lessons_count'),
('is_group_chat_locked', 'is_professor_chat_locked'),
('is_online', 'online_link')
),
'classes': ['tab'],
}),
(_('Media'), {
'fields': (
('video_type', 'video_file', 'video_link'),
),
'classes': ['tab'],
}),
(_('Pricing'), {
'fields': (
('is_free', 'price'),
('discount_percentage', 'final_price')
),
'classes': ['tab'],
}),
(_('Advanced Configuration'), {
'fields': ('timing', 'features'),
'classes': ['tab'],
}),
)
@display(description=_("Course"), header=True)
def display_header(self, instance):
from django.templatetags.static import static
thumbnail_path = instance.thumbnail.url if instance.thumbnail else None
return [
instance.title,
None, None,
{
"path": thumbnail_path,
"height": 40,
"width": 60,
"squared": True,
"borderless": True,
},
]
@display(description=_("Professor"))
def display_professor(self, instance):
return instance.professor.fullname
@display(description=_("Price"))
def display_price(self, instance):
if instance.is_free:
return format_html('<span class="text-green-600 font-medium">{}</span>', _("Free"))
if instance.discount_percentage > 0:
return format_html(
'<span class="line-through text-gray-400 mr-2">${}</span>'
'<span class="text-green-600 font-medium">${}</span>',
instance.price,
instance.final_price
)
return format_html('<span>${}</span>', instance.final_price)
actions_row = ["add_student_to_course"]
actions_detail = ['add_student_to_course', 'manage_all_students']
def has_is_course_professor_permission(self, request, object_id=None):
try:
if request.user.is_staff:
return True
course = self.get_object(request, object_id)
return course and request.user.can_manage_course(course)
except Exception as e:
return False
@action(
description=_("Add Student"),
icon="person_add",
permissions=["is_course_professor"],
)
def add_student_to_course(self, request, object_id):
course = self.get_object(request, object_id)
if not course:
messages.error(request, _("Course not found"))
return redirect(admin_url_generator(request, "course_course_changelist"))
if request.method == 'POST':
form = AddStudentForm(request.POST)
if form.is_valid():
student = form.cleaned_data['student']
if Participant.objects.filter(student=student, course=course).exists():
messages.warning(request, _("Student {} is already enrolled in this course").format(student.fullname))
else:
if not student.has_role('student'):
student.add_role('student')
Participant.objects.create(student=student, course=course)
messages.success(request, _("Student {} has been successfully added to {}").format(student.fullname, course.title))
return redirect(admin_url_generator(request, "course_course_changelist"))
else:
form = AddStudentForm()
return render(
request,
"course/add_student_form.html",
{
"form": form,
"object": object,
"title": _("Add Student to {}").format(course.title),
**self.admin_site.each_context(request),
},
)
@action(
description=_("Manage All Students"),
icon="groups",
permissions=["is_course_professor"],
)
def manage_all_students(self, request, object_id):
course = self.get_object(request, object_id)
if not course:
messages.error(request, _("Course not found"))
return redirect(admin_url_generator(request, "course_course_changelist"))
base_url = admin_url_generator(request, "course_participant_changelist")
url = f"{base_url}?course__id__exact={object_id}"
return redirect(url)
class GlossaryAdmin(AttachmentGlossaryBaseAdmin):
list_display = ('title', 'description')
search_fields = ('title', 'description')
ordering = ('-id',)
def is_used_in_professor_courses(self, user, obj):
return obj.courseglossary_set.filter(course__professor=user).exists()
def filter_by_professor_usage(self, user, queryset):
return queryset.filter(courseglossary__course__professor=user).distinct()
class CourseGlossaryAdmin(CourseRelatedAdmin):
list_display = ('course', 'glossary_title', 'glossary_description')
list_filter = ('course',)
search_fields = ('glossary__title', 'glossary__description', 'course__title')
ordering = ('-id',)
autocomplete_fields = ('course', 'glossary')
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
def has_module_permission(self, request):
return False
@admin.display(description=_("Title"))
def glossary_title(self, obj):
return obj.glossary.title
@admin.display(description=_("Description"))
def glossary_description(self, obj):
return obj.glossary.description
class AttachmentAdminForm(forms.ModelForm):
class Meta:
model = Attachment
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'file' in self.data or 'file' in self.files:
file = self.files.get('file')
if file:
file.name = self._shorten_file_name(file.name)
def _shorten_file_name(self, file_name):
max_length = 100
if len(file_name) > max_length:
base_name, ext = os.path.splitext(file_name)
allowed_length = max_length - len(ext)
base_length = int(allowed_length * 0.8)
hash_length = allowed_length - base_length
base_part = base_name[:base_length]
hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length]
return f"{base_part}{hash_part}{ext}"
return file_name
class AttachmentAdmin(AttachmentGlossaryBaseAdmin):
form = AttachmentAdminForm
list_display = ('title', 'file', 'file_size')
search_fields = ('title', 'file')
def save_model(self, request, obj, form, change):
if obj.file:
obj.file_size = obj.file.size
super().save_model(request, obj, form, change)
def is_used_in_professor_courses(self, user, obj):
return obj.courseattachment_set.filter(course__professor=user).exists()
def filter_by_professor_usage(self, user, queryset):
return queryset.filter(courseattachment__course__professor=user).distinct()
class CourseAttachmentAdmin(CourseRelatedAdmin):
list_display = ('course', 'attachment_title', 'attachment_file', 'attachment_file_size')
list_filter = ('course',)
search_fields = ('attachment__title', 'course__title')
autocomplete_fields = ('course', 'attachment')
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
def has_module_permission(self, request):
return False
@admin.display(description=_("Title"))
def attachment_title(self, obj):
return obj.attachment.title
@admin.display(description=_("File"))
def attachment_file(self, obj):
return obj.attachment.file
@admin.display(description=_("File Size"))
def attachment_file_size(self, obj):
return obj.attachment.file_size
class ParticipantAdmin(ModelAdmin):
list_display = ('student_name', 'course_title', 'joined_date',)
list_filter = (
('course', MultipleRelatedDropdownFilter),
)
search_fields = ('student__email', 'student__fullname', 'course__title')
readonly_fields = ('joined_date',)
autocomplete_fields = ('student', 'course')
fieldsets = (
(None, {
'fields': ('student', 'course', 'is_active')
}),
(_('Enrollment Details'), {
'fields': ('joined_date', 'unread_messages_count')
}),
)
@display(description=_("Student"), header=True)
def student_name(self, instance):
from django.templatetags.static import static
avatar_path = instance.student.avatar.url if getattr(instance.student, 'avatar', None) else static("images/reading(1).png")
return [
instance.student.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 36,
"borderless": True,
},
]
@display(description=_("Course"))
def course_title(self, obj):
if obj.course:
return obj.course.title
return "-"
# =========================================================
# REGISTRATIONS
# =========================================================
from django.contrib import admin as django_admin
try:
django_admin.site.register(Course, CourseAdmin)
django_admin.site.register(CourseCategory, CourseCategoryAdmin)
django_admin.site.register(Glossary, GlossaryAdmin)
django_admin.site.register(Attachment, AttachmentAdmin)
except django_admin.sites.AlreadyRegistered:
pass
project_admin_site.register(Course, CourseAdmin)
project_admin_site.register(CourseCategory, CourseCategoryAdmin)
project_admin_site.register(Glossary, GlossaryAdmin)
project_admin_site.register(CourseGlossary, CourseGlossaryAdmin)
project_admin_site.register(Attachment, AttachmentAdmin)
project_admin_site.register(CourseAttachment, CourseAttachmentAdmin)
project_admin_site.register(Participant, ParticipantAdmin)
class HiddenCourseAdmin(ModelAdmin):
search_fields = ('title', 'id')
def has_module_permission(self, request):
return False
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
dovoodi_admin_site.register(Course, HiddenCourseAdmin)

126
apps/course/admin/lesson.py

@ -1,126 +0,0 @@
import os
from django.contrib import admin
from django import forms
from django.utils.translation import gettext_lazy as _
from django.utils.html import format_html
from unfold.admin import ModelAdmin
from unfold.decorators import display
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
)
from unfold.widgets import UnfoldAdminRadioSelectWidget
from utils.admin import project_admin_site
from .professor_base import CourseRelatedAdmin
from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion
class LessonForm(forms.ModelForm):
class Meta:
model = Lesson
fields = '__all__'
class CourseLessonForm(forms.ModelForm):
class Meta:
model = CourseLesson
fields = '__all__'
class LessonAdmin(ModelAdmin):
form = LessonForm
list_display = ('title', 'display_duration', 'content_type')
list_filter = (
('content_type', ChoicesDropdownFilter),
)
search_fields = ('title',)
ordering = ('title',)
list_filter_submit = True
radio_fields = {
"content_type": admin.HORIZONTAL,
}
conditional_fields = {
'content_file': "content_type == 'video_file'",
'video_link': "content_type == 'youtube_link'",
}
fieldsets = (
(None, {
'fields': (('title', 'duration'),)
}),
(_('Content'), {
'fields': ('content_type', 'content_file', 'video_link'),
}),
)
def get_form(self, request, obj=None, change=False, **kwargs):
form = super().get_form(request, obj, change, **kwargs)
form.base_fields["content_type"].widget = UnfoldAdminRadioSelectWidget(
choices=Lesson.ContentTypeChoices.choices,
radio_style=admin.HORIZONTAL,
attrs={
"class": "radio-inline flex gap-4 p-2 rounded-lg bg-gray-50 shadow-sm",
"option_class": "flex items-center p-2 rounded-md hover:bg-white hover:shadow-sm transition-all duration-200",
"label_class": "ml-2 font-medium text-gray-700 cursor-pointer",
"input_class": "form-radio h-5 w-5 text-blue-600 transition duration-150 ease-in-out cursor-pointer",
},
)
return form
@display(description=_("Duration"))
def display_duration(self, obj):
return format_html('<span class="badge badge-info">{} {}</span>', obj.duration, _("min"))
class CourseLessonAdmin(CourseRelatedAdmin):
form = CourseLessonForm
list_display = ('title', 'course', 'display_duration', 'is_active', 'priority')
list_filter = (
('course', MultipleRelatedDropdownFilter),
'is_active',
)
search_fields = ('title', 'course__title')
ordering = ('course', 'priority')
autocomplete_fields = ('course', 'lesson')
list_filter_submit = True
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
def has_module_permission(self, request):
return False
fieldsets = (
(None, {
'fields': ('course', 'lesson', 'title', ('priority', 'is_active'))
}),
)
@display(description=_("Duration"))
def display_duration(self, obj):
return format_html('<span class="badge badge-info">{} {}</span>', obj.lesson.duration, _("min"))
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.order_by('course', 'priority')
class LessonCompletionAdmin(ModelAdmin):
list_display = ('student', 'course_lesson', 'completed_at')
search_fields = ('student__fullname', 'student__email', 'course_lesson__title', 'course_lesson__course__title')
list_filter = ('course_lesson__course', 'completed_at')
ordering = ('-completed_at',)
def get_readonly_fields(self, request, obj=None):
if obj:
return ['student', 'course_lesson', 'completed_at']
return []
# Registrations
from django.contrib import admin as django_admin
django_admin.site.register(Lesson, LessonAdmin)
project_admin_site.register(Lesson, LessonAdmin)
project_admin_site.register(CourseLesson, CourseLessonAdmin)
project_admin_site.register(LessonCompletion, LessonCompletionAdmin)

177
apps/course/admin/live_session.py

@ -1,177 +0,0 @@
from django.contrib import admin
from django import forms
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin, StackedInline
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
RangeDateFilter,
)
from utils.admin import project_admin_site
from apps.course.models import (
CourseLiveSession,
LiveSessionRecording,
LiveSessionUser,
USER_ROLE_CHOICES,
RECORDING_TYPE_CHOICES,
)
from django.contrib.auth import get_user_model
User = get_user_model()
# 🔔 CUSTOM FILTER: Only show active, non-guest users in the right sidebar filter
class ActiveUserDropdownFilter(MultipleRelatedDropdownFilter):
def field_choices(self, field, request, model_admin):
return field.get_choices(
include_blank=False,
limit_choices_to={'is_active': True, 'email__isnull': False}
)
# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Target the dropdown fields to ensure they don't collapse
target_dropdown_fields = ['user', 'role', 'recording_type']
for field_name, field in self.fields.items():
if field_name in target_dropdown_fields:
if hasattr(field.widget, 'attrs'):
existing_class = field.widget.attrs.get('class', '')
field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
existing_style = field.widget.attrs.get('style', '')
field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
# 🔔 Add the custom placeholder text
if hasattr(field, 'empty_label'):
field.empty_label = _("Select a value")
class LiveSessionUserInline(StackedInline):
model = LiveSessionUser
form = MinWidthInlineForm
extra = 1
tab = True
fieldsets = (
(None, {
'fields': (('user', 'role', 'is_online'),) # Grouped horizontally
}),
(_("Timing"), {
'fields': (('entered_at', 'exited_at'),) # Grouped horizontally
}),
)
show_change_link = True
verbose_name = _("Session User")
verbose_name_plural = _("Session Users")
# 🔔 FILTER THE USER DROPDOWN IN THE FORM ITSELF
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class LiveSessionRecordingInline(StackedInline):
model = LiveSessionRecording
form = MinWidthInlineForm
extra = 1
tab = True
fieldsets = (
(None, {
'fields': (('title', 'recording_type', 'is_active'),) # Grouped horizontally
}),
(_("Media"), {
'fields': ('file',)
}),
)
show_change_link = True
verbose_name = _("Session Recording")
verbose_name_plural = _("Session Recordings")
class CourseLiveSessionAdmin(ModelAdmin):
list_display = ("subject", "course", "started_at", "ended_at", "created_at")
list_filter = (
("course", MultipleRelatedDropdownFilter),
("started_at", RangeDateFilter),
)
search_fields = ("subject", "course__title")
ordering = ("-started_at",)
autocomplete_fields = ("course",)
readonly_fields = ("created_at", "updated_at")
# Add the custom tabs here
inlines = [LiveSessionUserInline, LiveSessionRecordingInline]
fieldsets = (
(None, {"fields": ("course", "subject", "started_at", "ended_at")}),
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
)
class LiveSessionUserAdmin(ModelAdmin):
list_display = ("user", "session", "role", "is_online", "entered_at", "exited_at")
list_filter = (
("session", MultipleRelatedDropdownFilter),
("user", ActiveUserDropdownFilter), # 🔔 APPLIED CUSTOM FILTER HERE!
("role", ChoicesDropdownFilter),
("entered_at", RangeDateFilter),
("is_online", admin.BooleanFieldListFilter),
)
search_fields = (
"user__email",
"user__fullname",
"session__subject",
)
autocomplete_fields = ("user", "session")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("session", "user", "role")}),
(_("Session Timing"), {"fields": ("entered_at", "exited_at", "is_online")}),
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
)
def get_role_choices(self, request):
return USER_ROLE_CHOICES
# FILTER THE STANDALONE ADMIN FORM AS WELL JUST IN CASE
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class LiveSessionRecordingAdmin(ModelAdmin):
list_display = ("title", "session", "recording_type", "is_active", "created_at")
list_filter = (
("session", MultipleRelatedDropdownFilter),
("recording_type", ChoicesDropdownFilter),
("created_at", RangeDateFilter),
("is_active", admin.BooleanFieldListFilter),
)
search_fields = ("title", "session__subject", "session__course__title")
autocomplete_fields = ("session",)
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("session", "title", "recording_type")}),
(_("Files"), {"fields": ("file", "file_time", "thumbnail")}),
(_("Status"), {"fields": ("is_active",)}),
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
)
def get_recording_type_choices(self, request):
return RECORDING_TYPE_CHOICES
project_admin_site.register(CourseLiveSession, CourseLiveSessionAdmin)
project_admin_site.register(LiveSessionUser, LiveSessionUserAdmin)
project_admin_site.register(LiveSessionRecording, LiveSessionRecordingAdmin)

33
apps/course/admin/participant.py

@ -1,33 +0,0 @@
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', 'unread_messages_count')
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

181
apps/course/admin/professor_base.py

@ -1,181 +0,0 @@
"""
Base admin classes برای استادان
"""
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin as UnfoldModelAdmin
class ProfessorBaseAdmin(UnfoldModelAdmin):
"""Base admin class برای استادان"""
def has_module_permission(self, request):
"""آیا کاربر می‌تواند این ماژول را ببیند؟"""
# چک کردن احراز هویت
if not request.user.is_authenticated:
return False
# اولویت اول: staff یا admin
if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
return True
# اولویت دوم: professor
return request.user.has_role('professor')
def has_view_permission(self, request, obj=None):
"""آیا می‌تواند مشاهده کند؟"""
# چک کردن احراز هویت
if not request.user.is_authenticated:
return False
# اولویت اول: staff یا admin - دسترسی کامل
if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
return True
# اولویت دوم: professor - دسترسی محدود
if request.user.has_role('professor'):
if obj is None:
return True
return self.can_access_object(request.user, obj)
return False
def has_add_permission(self, request):
"""آیا می‌تواند اضافه کند؟"""
# چک کردن احراز هویت
if not request.user.is_authenticated:
return False
# اولویت اول: staff یا admin - دسترسی کامل
if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
return True
# اولویت دوم: professor
return request.user.has_role('professor')
def has_change_permission(self, request, obj=None):
"""آیا می‌تواند تغییر دهد؟"""
# چک کردن احراز هویت
if not request.user.is_authenticated:
return False
# اولویت اول: staff یا admin - دسترسی کامل
if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
return True
# اولویت دوم: professor - دسترسی محدود
if request.user.has_role('professor'):
if obj is None:
return True
return self.can_access_object(request.user, obj)
return False
def has_delete_permission(self, request, obj=None):
"""آیا می‌تواند حذف کند؟"""
# چک کردن احراز هویت
if not request.user.is_authenticated:
return False
# اولویت اول: staff یا admin - دسترسی کامل
if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
return True
# اولویت دوم: professor - دسترسی محدود
if request.user.has_role('professor'):
if obj is None:
return True
return self.can_access_object(request.user, obj)
return False
def can_access_object(self, user, obj):
"""آیا کاربر می‌تواند به این object دسترسی داشته باشد؟"""
# این method باید در subclass ها override شود
return True
def get_queryset(self, request):
"""فیلتر کردن queryset بر اساس دسترسی کاربر"""
qs = super().get_queryset(request)
# چک کردن احراز هویت
if not request.user.is_authenticated:
return qs.none()
# اولویت اول: staff یا admin - دسترسی کامل
if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
return qs
# اولویت دوم: professor - دسترسی محدود
if request.user.has_role('professor'):
return self.filter_queryset_for_professor(request, qs)
return qs.none()
def filter_queryset_for_professor(self, request, queryset):
"""فیلتر کردن queryset برای استاد"""
# این method باید در subclass ها override شود
return queryset
class CourseRelatedAdmin(ProfessorBaseAdmin):
"""Base admin برای مدل‌هایی که به Course مرتبط هستند"""
def can_access_object(self, user, obj):
"""چک کردن دسترسی بر اساس Course"""
course = self.get_course_from_object(obj)
if course:
return user.can_manage_course(course)
return False
def filter_queryset_for_professor(self, request, queryset):
"""فیلتر کردن بر اساس دوره‌های استاد"""
return queryset.filter(course__professor=request.user)
def get_course_from_object(self, obj):
"""دریافت Course از object"""
# این method باید در subclass ها override شود
if hasattr(obj, 'course'):
return obj.course
return None
class DirectCourseAdmin(ProfessorBaseAdmin):
"""Admin برای خود مدل Course"""
def can_access_object(self, user, obj):
"""چک کردن دسترسی به Course"""
return user.can_manage_course(obj)
def filter_queryset_for_professor(self, request, queryset):
"""فقط دوره‌های خود استاد"""
return queryset.filter(professor=request.user)
class AttachmentGlossaryBaseAdmin(ProfessorBaseAdmin):
"""Base admin برای Attachment و Glossary"""
def can_access_object(self, user, obj):
"""چک کردن دسترسی - فقط اگر در دوره‌های استاد استفاده شده"""
# چک کنیم که آیا این attachment/glossary در دوره‌های استاد استفاده شده
return self.is_used_in_professor_courses(user, obj)
def filter_queryset_for_professor(self, request, queryset):
"""فیلتر کردن بر اساس استفاده در دوره‌های استاد"""
return self.filter_by_professor_usage(request.user, queryset)
def is_used_in_professor_courses(self, user, obj):
"""آیا در دوره‌های استاد استفاده شده؟"""
# باید در subclass ها پیاده‌سازی شود
return True
def filter_by_professor_usage(self, user, queryset):
"""فیلتر کردن بر اساس استفاده در دوره‌های استاد"""
# باید در subclass ها پیاده‌سازی شود
return queryset
class CertificateBaseAdmin(ProfessorBaseAdmin):
"""Base admin برای Certificate"""
def can_access_object(self, user, obj):
"""چک کردن دسترسی به Certificate"""
# فقط certificate های دانش‌آموزان دوره‌های خودش
if hasattr(obj, 'course') and obj.course:
return user.can_manage_course(obj.course)
return False
def filter_queryset_for_professor(self, request, queryset):
"""فقط certificate های دانش‌آموزان دوره‌های استاد"""
return queryset.filter(course__professor=request.user)

9
apps/course/apps.py

@ -1,9 +0,0 @@
from django.apps import AppConfig
class CourseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.course'
def ready(self):
import apps.course.signals

42
apps/course/data/category.json

@ -1,42 +0,0 @@
[
{
"id": 8,
"name": "Комплексный годовой курс",
"slug": "kompleksnyi-godovoi-kurs"
},
{
"id": 7,
"name": "Исламская философия",
"slug": "islamskaia-filosofiia"
},
{
"id": 6,
"name": "Арабский диалог",
"slug": "arabskii-dialog"
},
{
"id": 5,
"name": "грамматике арабского языка",
"slug": "grammatike-arabskogo-iazyka"
},
{
"id": 4,
"name": "Персидский язык",
"slug": "persidskii-iazyk"
},
{
"id": 3,
"name": "исламской философии",
"slug": "islamskoi-filosofii"
},
{
"id": 2,
"name": "Толкование корана",
"slug": "tolkovanie-korana"
},
{
"id": 1,
"name": "Таджвид Корана",
"slug": "tadzhvid-korana"
}
]

430
apps/course/doc.py

@ -1,430 +0,0 @@
def doc_course_participants():
return """
# 🐈 Scenario
🛠 لیست شرکتکنندگان دوره
---
## 🚀 درخواست API
### URL:
```
GET /api/courses/<slug>/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 مشخص میکند آیا کاربر این درس را گذرانده است
ممکن است درس دارای کوعیز باشد که باید در زیر آ» مانند طرح نمایش داده شود
)
دارای ابجکت کوعیز که لیستی از کوعیز های مربوط به یک درس را نمایش میدهد
بایستی مانند طرح در زیر درس قرار داده شود
و دارای مقدار permission
است که مشخص میکند ایا این کاربر کوعیز را از قبل شرکت کرده است
---
```
## 📄 توضیحات مقادیر پاسخ
| کلید | نوع داده | توضیحات |
|-------------------------|------------|----------------------------------------------------------|
| `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,
"quizs": [
{
"id": 1,
"title": "Тестовые курсы",
"description": "урок 1-2",
"permission": true,
"each_question_timing": 30
}
]
},
{
"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,
"quizs": null
}
]
```
"""
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
}
]
```
"""

0
apps/course/management/__init__.py

0
apps/course/management/commands/__init__.py

134
apps/course/management/commands/clear_course_data.py

@ -1,134 +0,0 @@
from django.core.management.base import BaseCommand
from django.db import transaction, connection
from django.db.models import ProtectedError
from django.utils.translation import gettext_lazy as _
from apps.course.models import (
Course, CourseCategory,
Lesson, CourseLesson, LessonCompletion,
Attachment, CourseAttachment,
Glossary, CourseGlossary,
Participant
)
class Command(BaseCommand):
help = _('Clear all course-related data from the database')
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
dest='force',
help=_('Force deletion without confirmation'),
)
parser.add_argument(
'--model',
type=str,
dest='model',
help=_('Specify a single model to clear (e.g., "Course", "Lesson", etc.)'),
)
parser.add_argument(
'--legacy-only',
action='store_true',
dest='legacy_only',
help=_('Clear only legacy models (before migration to new structure)'),
)
def table_exists(self, table_name):
"""Check if a table exists in the database."""
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = %s
);
""",
[table_name]
)
return cursor.fetchone()[0]
def handle(self, *args, **options):
force = options['force']
specific_model = options.get('model')
legacy_only = options.get('legacy_only')
if not force and not specific_model:
confirm = input(_('This will delete ALL course-related data. Are you sure? (yes/no): '))
if confirm.lower() != 'yes':
self.stdout.write(self.style.WARNING(_('Operation cancelled.')))
return
# Define all models
all_models = {
'Course': (Course, 'course_course'),
'CourseCategory': (CourseCategory, 'course_coursecategory'),
'Lesson': (Lesson, 'course_lesson'),
'CourseLesson': (CourseLesson, 'course_courselesson'),
'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'),
'Attachment': (Attachment, 'course_attachment'),
'CourseAttachment': (CourseAttachment, 'course_courseattachment'),
'Glossary': (Glossary, 'course_glossary'),
'CourseGlossary': (CourseGlossary, 'course_courseglossary'),
'Participant': (Participant, 'course_participant'),
}
# Legacy models (before migration)
legacy_models = {
'Course': (Course, 'course_course'),
'CourseCategory': (CourseCategory, 'course_coursecategory'),
'Lesson': (Lesson, 'course_lesson'),
'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'),
'Attachment': (Attachment, 'course_attachment'),
'Glossary': (Glossary, 'course_glossary'),
'Participant': (Participant, 'course_participant'),
}
models_to_use = legacy_models if legacy_only else all_models
if specific_model:
# Clear only the specified model
if specific_model not in models_to_use:
self.stdout.write(self.style.ERROR(_(f'Unknown model: {specific_model}')))
self.stdout.write(self.style.WARNING(_(f'Available models: {", ".join(models_to_use.keys())}')))
return
model_info = models_to_use[specific_model]
models_to_clear = [(specific_model, model_info[0], model_info[1])]
else:
# Clear all models in the correct order to avoid foreign key constraints
models_to_clear = []
# Order matters for foreign key constraints
model_order = [
'LessonCompletion', 'CourseLesson', 'Lesson',
'CourseAttachment', 'Attachment',
'CourseGlossary', 'Glossary',
'Participant', 'Course', 'CourseCategory'
]
for model_name in model_order:
if model_name in models_to_use:
model_info = models_to_use[model_name]
models_to_clear.append((model_name, model_info[0], model_info[1]))
# Process each model
for model_name, model_class, table_name in models_to_clear:
# Check if the table exists
if not self.table_exists(table_name):
self.stdout.write(self.style.WARNING(_(f'Table {table_name} does not exist, skipping {model_name}')))
continue
try:
count = model_class.objects.count()
model_class.objects.all().delete()
self.stdout.write(self.style.SUCCESS(_(f'Deleted {count} {model_name} records')))
except ProtectedError as e:
self.stdout.write(self.style.ERROR(_(f'Could not delete {model_name} records due to protected foreign keys')))
self.stdout.write(self.style.ERROR(str(e)))
except Exception as e:
self.stdout.write(self.style.ERROR(_(f'Error deleting {model_name} records: {str(e)}')))
self.stdout.write(self.style.SUCCESS(_('Course data clearing completed')))

1007
apps/course/migrations/0001_initial.py
File diff suppressed because it is too large
View File

23
apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py

@ -1,23 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-26 15:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='course',
name='is_chat_group_lock',
field=models.BooleanField(default=False, verbose_name='Lock Group Chat'),
),
migrations.AddField(
model_name='course',
name='is_prof_chat_lock',
field=models.BooleanField(default=False, verbose_name='Lock Private Chats with Professor'),
),
]

23
apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py

@ -1,23 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-26 15:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('course', '0002_course_is_chat_group_lock_course_is_prof_chat_lock'),
]
operations = [
migrations.RenameField(
model_name='course',
old_name='is_chat_group_lock',
new_name='is_group_chat_locked',
),
migrations.RenameField(
model_name='course',
old_name='is_prof_chat_lock',
new_name='is_professor_chat_locked',
),
]

58
apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py

@ -1,58 +0,0 @@
# Generated by Django 5.2.12 on 2026-05-03 14:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0002_alter_user_email_alter_user_username'),
('course', '0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='lessoncompletion',
options={'verbose_name': 'Lesson Completion', 'verbose_name_plural': 'Lesson Completions'},
),
migrations.AlterModelOptions(
name='participant',
options={'verbose_name': 'Participant', 'verbose_name_plural': 'Participants'},
),
migrations.AlterField(
model_name='lessoncompletion',
name='course_lesson',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.courselesson', verbose_name='Course Lesson'),
),
migrations.AlterField(
model_name='lessoncompletion',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_completions', to='account.studentuser', verbose_name='Student'),
),
migrations.AlterField(
model_name='participant',
name='course',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='course.course', verbose_name='Course'),
),
migrations.AlterField(
model_name='participant',
name='is_active',
field=models.BooleanField(default=True, verbose_name='Is Active'),
),
migrations.AlterField(
model_name='participant',
name='joined_date',
field=models.DateTimeField(auto_now_add=True, verbose_name='Joined Date'),
),
migrations.AlterField(
model_name='participant',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participated_courses', to='account.studentuser', verbose_name='Student'),
),
migrations.AlterField(
model_name='participant',
name='unread_messages_count',
field=models.IntegerField(default=0, verbose_name='Unread Messages Count'),
),
]

19
apps/course/migrations/0005_alter_course_discount_percentage.py

@ -1,19 +0,0 @@
# Generated by Django 5.2.12 on 2026-05-04 09:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0004_alter_lessoncompletion_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='course',
name='discount_percentage',
field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Discount Percentage'),
),
]

31
apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py

@ -1,31 +0,0 @@
# Generated by Django 5.2.12 on 2026-05-04 12:53
import apps.course.models.course
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0003_alter_clientuser_options_and_more'),
('course', '0005_alter_course_discount_percentage'),
]
operations = [
migrations.AlterField(
model_name='course',
name='professor',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='account.professoruser', verbose_name='Professor'),
),
migrations.AlterField(
model_name='course',
name='video_file',
field=models.FileField(blank=True, null=True, upload_to=apps.course.models.course.course_file_upload_to, verbose_name='Video File'),
),
migrations.AlterField(
model_name='course',
name='video_link',
field=models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link'),
),
]

0
apps/course/migrations/__init__.py

4
apps/course/models/__init__.py

@ -1,4 +0,0 @@
from .course import *
from .lesson import *
from .participant import *
from .live_session import *

275
apps/course/models/course.py

@ -1,275 +0,0 @@
import os
from decimal import Decimal
import math
from django.db import models
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
from apps.account.models import ProfessorUser
from utils.schema import default_timing
from utils import generate_slug_for_model
from django.core.validators import MinValueValidator, MaxValueValidator
def course_file_upload_to(instance, filename):
return os.path.join(f"courses/{instance.slug}/videos/{filename}")
def attachment_file_upload_to(instance, filename):
return os.path.join(f"attachments/{filename}")
def course_attachment_file_upload_to(instance, filename):
return os.path.join(f"courses/{instance.course.slug}/attachments/{filename}")
class CourseCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_('Category Name'))
slug = models.SlugField(unique=True, max_length=255)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug_for_model(CourseCategory, self.name)
super().save(*args, **kwargs)
@property
def course_count(self):
return self.courses.exclude(status="inactive").count()
class Course(models.Model):
class LevelChoices(TextChoices):
BEGINNER = 'beginner', _('Beginner')
MID = 'mid', _('Mid Level')
ADVANCED = 'advanced', _('Advanced')
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)-закончился
class VedioTypeChoices(models.TextChoices):
YOUTUBE_LINK = 'youtube_link', _('Youtube Link')
VIDEO_FILE = 'video_file', _('Video File')
title = models.CharField(max_length=255, verbose_name=_('Course Title'))
slug = models.SlugField(allow_unicode=True, unique=True)
category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name=_('Category'))
professor = models.ForeignKey(
ProfessorUser,
on_delete=models.CASCADE,
related_name="courses",
verbose_name=_("Professor")
)
thumbnail = models.ImageField(upload_to="courses/thumbnails/", verbose_name=_('Thumbnail'))
video_type = models.CharField(
max_length=20,
choices=VedioTypeChoices.choices,
verbose_name=_('Preview Video Type (YouTube Link or File Upload)')
)
video_file = models.FileField(
upload_to=course_file_upload_to,
null=True,
blank=True,
verbose_name=_("Video File")
)
video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Video Link"))
is_online = models.BooleanField(default=False, verbose_name=_('Is Online Course'))
online_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Online Class Link'))
level = models.CharField(max_length=10, choices=LevelChoices.choices, 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(max_length=500, blank=True, null=True, verbose_name=_("Short Description"))
status = models.CharField(max_length=15, choices=StatusChoices.choices, default=StatusChoices.INACTIVE, verbose_name=_('Course Status'))
is_free = models.BooleanField(default=True, verbose_name=_('Is Free'))
price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price'))
discount_percentage = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], verbose_name=_('Discount Percentage'))
final_price = models.DecimalField(
verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True,
help_text=_('This field is automatically calculated based on the discount percentage.')
)
is_group_chat_locked = models.BooleanField(
default=False,
verbose_name=_('Lock Group Chat')
)
is_professor_chat_locked = models.BooleanField(
default=False,
verbose_name=_('Lock Private Chats with Professor')
)
timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing"))
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):
if not self.slug:
self.slug = generate_slug_for_model(Course, self.title)
# Ensure consistency: if price is 0, set is_free to True and discount_percentage to 0
if self.price == 0:
self.is_free = True
self.discount_percentage = 0
self.final_price = Decimal('0.00')
elif self.is_free:
self.price = Decimal('0.00')
self.discount_percentage = 0
self.final_price = Decimal('0.00')
elif self.discount_percentage > 0:
discount_amount = (self.price * self.discount_percentage) / 100
final_price = self.price - discount_amount
self.final_price = Decimal(math.ceil(final_price)).quantize(Decimal('0.00'))
else:
self.final_price = Decimal(math.ceil(self.price)).quantize(Decimal('0.00'))
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Course")
verbose_name_plural = _("Courses")
indexes = [
models.Index(fields=['status']),
models.Index(fields=['is_free']),
models.Index(fields=['created_at']),
models.Index(fields=['slug']),
models.Index(fields=['status', 'created_at']),
models.Index(fields=['category', 'status']),
models.Index(fields=['professor', 'status']),
]
class Glossary(models.Model):
"""
Base Glossary model that contains the actual content
"""
title = models.CharField(max_length=555, verbose_name=_('Glossary Title'))
description = models.TextField(verbose_name=_('Description'))
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
class Meta:
verbose_name = _("Glossary")
verbose_name_plural = _("Glossaries")
class CourseGlossary(models.Model):
"""
Intermediate model that connects Course with Glossary
"""
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name=_('Course'))
glossary = models.ForeignKey(Glossary, on_delete=models.CASCADE, related_name='course_glossaries', verbose_name=_('Glossary'))
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.glossary.title}"
@property
def title(self):
return self.glossary.title
@property
def description(self):
return self.glossary.description
class Meta:
ordering = ("-id",)
verbose_name = _("Course Glossary")
verbose_name_plural = _("Course Glossaries")
class Attachment(models.Model):
"""
Base Attachment model that contains the actual file
"""
title = models.CharField(max_length=255, verbose_name=_('Attachment Title'))
file = models.FileField(
upload_to=attachment_file_upload_to,
verbose_name=_('Attachment File')
)
file_size = models.PositiveIntegerField(verbose_name=_('File Size (in bytes)'), null=True, blank=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 save(self, *args, **kwargs):
# Calculate the file size before saving
if self.file and not self.file_size:
self.file_size = self.file.size
super().save(*args, **kwargs)
def __str__(self):
return self.title
class Meta:
verbose_name = _("Attachment")
verbose_name_plural = _("Attachments")
class CourseAttachment(models.Model):
"""
Intermediate model that connects Course with Attachment
"""
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name=_('Course'))
attachment = models.ForeignKey(Attachment, on_delete=models.CASCADE, related_name='course_attachments', verbose_name=_('Attachment'))
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.attachment.title}"
@property
def title(self):
return self.attachment.title
@property
def file(self):
return self.attachment.file
@property
def file_size(self):
return self.attachment.file_size
class Meta:
ordering = ("-id",)
verbose_name = _("Course Attachment")
verbose_name_plural = _("Course Attachments")
indexes = [
models.Index(fields=['course']),
models.Index(fields=['attachment']),
]

152
apps/course/models/lesson.py

@ -1,152 +0,0 @@
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):
return os.path.join(f"lessons/{filename}")
class Lesson(models.Model):
"""
Base Lesson model that contains the actual content (video file or link)
"""
class ContentTypeChoices(models.TextChoices):
YOUTUBE_LINK = 'youtube_link', _('Youtube Link')
VIDEO_FILE = 'video_file', _('Video File')
title = models.CharField(max_length=255, verbose_name=_('Lesson Title'))
content_type = models.CharField(max_length=50, choices=ContentTypeChoices.choices, verbose_name=_('Content Type'))
content_file = models.FileField(
null=True,
blank=True,
upload_to=lesson_file_upload_to,
)
video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Link'))
duration = models.PositiveIntegerField(verbose_name=_('Duration (in minutes)'))
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
class Meta:
verbose_name = _("Lesson")
verbose_name_plural = _("Lessons")
indexes = [
models.Index(fields=['content_type']),
models.Index(fields=['created_at']),
]
class CourseLesson(models.Model):
"""
Intermediate model that connects Course with Lesson
"""
course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Course'))
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='course_lessons', verbose_name=_('Lesson'))
title = models.CharField(max_length=255, verbose_name=_('Course Lesson Title'), null=True, blank=True)
priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority'))
is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
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):
title = self.title or self.lesson.title
return f"{self.course.title} - {title}"
def is_completed_by(self, student):
return self.completions.filter(student=student).exists()
@property
def content_type(self):
return self.lesson.content_type
@property
def content_file(self):
return self.lesson.content_file
@property
def video_link(self):
return self.lesson.video_link
@property
def duration(self):
return self.lesson.duration
def save(self, *args, **kwargs):
# If title is not provided, use the lesson's title
if not self.title:
self.title = self.lesson.title
if self.priority is None:
# If priority is not set, set it to the next available priority
max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority']
self.priority = (max_priority or 0) + 1
else:
self._adjust_priorities()
super().save(*args, **kwargs)
def _adjust_priorities(self):
# Adjust priorities of other lessons in the course
lessons = self.course.lessons.exclude(pk=self.pk)
# Shift priorities for lessons with the same or higher priority
lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1)
class Meta:
verbose_name = _("Course Lesson")
verbose_name_plural = _("Course Lessons")
indexes = [
models.Index(fields=['course']),
models.Index(fields=['lesson']),
models.Index(fields=['priority']),
models.Index(fields=['is_active']),
models.Index(fields=['course', 'priority']),
models.Index(fields=['course', 'is_active']),
]
class LessonCompletion(models.Model):
student = models.ForeignKey(
StudentUser,
on_delete=models.CASCADE,
related_name='lesson_completions',
verbose_name=_('Student')
)
course_lesson = models.ForeignKey(
CourseLesson,
on_delete=models.CASCADE,
related_name='completions',
verbose_name=_('Course Lesson')
)
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', 'course_lesson')
verbose_name = _("Lesson Completion")
verbose_name_plural = _("Lesson Completions")
indexes = [
models.Index(fields=['student']),
models.Index(fields=['course_lesson']),
models.Index(fields=['completed_at']),
models.Index(fields=['student', 'course_lesson']),
]
def __str__(self):
return f"{self.student.fullname} - {self.course_lesson.title} - Completed"

189
apps/course/models/live_session.py

@ -1,189 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from .course import Course
from apps.account.models import User
class CourseLiveSession(models.Model):
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
related_name="live_sessions",
verbose_name=_("Course"),
help_text=_("Course that this live session belongs to."),
)
room_id = models.CharField(
max_length=255,
verbose_name=_("Room ID"),
help_text=_("Identifier of the PlugNMeet room."),
unique=True,
null=True,
blank=True,
)
subject = models.CharField(
max_length=255,
verbose_name=_("Subject"),
help_text=_("Topic of the live session."),
)
started_at = models.DateTimeField(
verbose_name=_("Started At"),
help_text=_("Start time of the live session."),
)
ended_at = models.DateTimeField(
verbose_name=_("Ended At"),
help_text=_("End time of the live session."),
null=True,
blank=True,
)
recorded_file = models.FileField(
upload_to="live_session_recordings/",
verbose_name=_("Recorded File"),
help_text=_("Recorded file of the live session."),
null=True,
blank=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 f"{self.course} - {self.subject}"
class Meta:
ordering = ("-started_at", "-id")
verbose_name = _("Course Live Session")
verbose_name_plural = _("Course Live Sessions")
indexes = [
models.Index(fields=["course", "started_at"]),
models.Index(fields=["course", "created_at"]),
models.Index(fields=["room_id"]),
]
USER_ROLE_CHOICES = (
("participant", _("Participant")),
("moderator", _("Moderator")),
("observer", _("Observer")),
)
class LiveSessionUser(models.Model):
session = models.ForeignKey(
CourseLiveSession,
on_delete=models.CASCADE,
related_name="user_sessions",
verbose_name=_("Live Session"),
help_text=_("Live session that the user joined."),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="live_session_entries",
verbose_name=_("User"),
help_text=_("User participating in the live session."),
)
role = models.CharField(
max_length=50,
choices=USER_ROLE_CHOICES,
verbose_name=_("Role"),
help_text=_("Role of the user in the session"),
)
entered_at = models.DateTimeField(
verbose_name=_("Entered At"),
help_text=_("Time the user entered the session"),
)
exited_at = models.DateTimeField(
verbose_name=_("Exited At"),
help_text=_("Time the user exited the session"),
null=True,
blank=True,
default=None,
)
is_online = models.BooleanField(
default=True,
verbose_name=_("Is online"),
help_text=_("Is the user currently online?"),
)
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.user} @ {self.session}"
class Meta:
verbose_name = _("User Session")
verbose_name_plural = _("User Sessions")
ordering = ("-entered_at", "-id")
indexes = [
models.Index(fields=["session", "user"]),
models.Index(fields=["session", "is_online"]),
models.Index(fields=["user", "is_online"]),
]
unique_together = ("session", "user", "entered_at")
RECORDING_TYPE_CHOICES = (
("voice", _("Voice")),
("video", _("Video")),
)
class LiveSessionRecording(models.Model):
session = models.ForeignKey(
CourseLiveSession,
on_delete=models.CASCADE,
related_name="recordings",
verbose_name=_("Live Session"),
help_text=_("Live session that this recording belongs to."),
)
title = models.CharField(
max_length=255,
verbose_name=_("Title"),
help_text=_("Title of the recording"),
)
file = models.FileField(
upload_to="recorded_sessions/",
verbose_name=_("Recording File"),
help_text=_("File of the recorded session"),
)
file_time = models.DurationField(
verbose_name=_("File Duration"),
help_text=_("Duration of the recording file"),
null=True,
blank=True,
)
recording_type = models.CharField(
max_length=10,
choices=RECORDING_TYPE_CHOICES,
verbose_name=_("Recording Type"),
help_text=_("Type of the recording (voice or video)"),
)
thumbnail = models.ImageField(
upload_to="recording_thumbnails/",
verbose_name=_("Thumbnail"),
help_text=_("Thumbnail image for video recordings"),
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"), help_text=_("Time the recording was created"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"), help_text=_("The datetime when the recording was last updated"))
is_active = models.BooleanField(
default=True,
verbose_name=_("Is Active"),
help_text=_("Whether this recording is active or not"),
)
def __str__(self):
meet_id = getattr(self.session, "meet_id", self.session_id)
return f"meet:<{meet_id}><{self.id}>{self.title} - {self.recording_type}"
class Meta:
verbose_name = _("Live Session Recording")
verbose_name_plural = _("Live Session Recordings")
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=["session", "is_active"]),
models.Index(fields=["session", "recording_type"]),
]

34
apps/course/models/participant.py

@ -1,34 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.account.models import StudentUser, User
from apps.course.models import Course
class Participant(models.Model):
student = models.ForeignKey(
StudentUser,
on_delete=models.CASCADE,
related_name='participated_courses',
verbose_name=_('Student')
)
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
related_name='participants',
verbose_name=_('Course')
)
is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
joined_date = models.DateTimeField(auto_now_add=True, verbose_name=_('Joined Date'))
unread_messages_count = models.IntegerField(default=0, verbose_name=_('Unread Messages Count'))
class Meta:
unique_together = ('student', 'course')
verbose_name = _('Participant')
verbose_name_plural = _('Participants')
indexes = [
models.Index(fields=['student']),
models.Index(fields=['course']),
models.Index(fields=['joined_date']),
models.Index(fields=['student', 'course']),
]

5
apps/course/serializers/__init__.py

@ -1,5 +0,0 @@
from .course import *
from .lesson import *
from .participant import *
from .online import *
from .professor import *

436
apps/course/serializers/course.py

@ -1,436 +0,0 @@
from rest_framework import serializers
# from dj_filer.admin import get_thumbs
from utils import get_thumbs
from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson, CourseAttachment, CourseGlossary, CourseLesson
from apps.chat.models import RoomMessage
from apps.account.serializers import UserProfileSerializer
class CourseCategorySerializer(serializers.ModelSerializer):
course_count = serializers.SerializerMethodField()
class Meta:
model = CourseCategory
fields = ['name', 'slug', 'course_count']
def get_course_count(self, obj):
return obj.course_count
class CourseListSerializer(serializers.ModelSerializer):
category = CourseCategorySerializer()
thumbnail = serializers.SerializerMethodField()
participant_count = serializers.SerializerMethodField()
lessons_count = serializers.SerializerMethodField()
price = serializers.SerializerMethodField()
discount_percentage = serializers.SerializerMethodField()
final_price = serializers.SerializerMethodField()
is_free = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
'id',
'title',
'slug',
'participant_count',
'category',
'thumbnail',
'is_online',
'online_link',
'level',
'duration',
'lessons_count',
'short_description',
'status',
'is_free',
'price',
'discount_percentage',
'final_price',
]
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_participant_count(self, obj):
return obj.participants.count()
def get_lessons_count(self, obj):
# Use prefetched lessons if available
if hasattr(obj, 'lessons') and obj.lessons.all():
lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active)
return max(lessons_count, obj.lessons_count)
# Fallback to direct query
lessons_count = obj.lessons.filter(is_active=True).count()
return max(lessons_count, obj.lessons_count)
def get_price(self, obj):
if obj.is_free or obj.price == 0:
return "0.00"
return str(obj.price)
def get_discount_percentage(self, obj):
if obj.is_free or obj.price == 0:
return 0
return obj.discount_percentage
def get_final_price(self, obj):
if obj.is_free or obj.price == 0:
return "0.00"
return str(obj.final_price)
def get_is_free(self, obj):
return obj.is_free or obj.price == 0
class CourseDetailSerializer(serializers.ModelSerializer):
category = CourseCategorySerializer()
professor = serializers.SerializerMethodField()
thumbnail = serializers.SerializerMethodField()
participant_count = serializers.SerializerMethodField()
access = serializers.SerializerMethodField()
lessons_complated_count = serializers.SerializerMethodField()
lessons_count = serializers.SerializerMethodField()
last_lesson_id = serializers.SerializerMethodField()
room_id = serializers.SerializerMethodField()
user_transaction_status = serializers.SerializerMethodField()
price = serializers.SerializerMethodField()
discount_percentage = serializers.SerializerMethodField()
final_price = serializers.SerializerMethodField()
is_free = serializers.SerializerMethodField()
is_professor = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
'id',
'title',
'slug',
'category',
'access',
'participant_count',
'professor',
'is_professor',
'thumbnail',
'video_type',
'video_file',
'video_link',
'is_online',
'online_link',
'level',
'description',
'duration',
'lessons_count',
'lessons_complated_count',
'short_description',
'status',
'is_free',
'price',
'discount_percentage',
'final_price',
'timing',
'features',
'last_lesson_id',
'room_id',
'user_transaction_status',
'is_group_chat_locked',
'is_professor_chat_locked'
]
def get_room_id(self, obj):
# Use prefetched room_messages if available
if hasattr(obj, 'room_messages') and obj.room_messages.all():
return obj.room_messages.first().id
# Fallback to direct query if not prefetched
room_message = RoomMessage.objects.filter(course=obj).first()
if room_message:
return room_message.id
return None
def get_user_transaction_status(self, obj):
from apps.transaction.models import TransactionParticipant
if student := self._get_authenticated_user():
latest_transaction = TransactionParticipant.objects.filter(
user=student,
course=obj,
is_deleted=False
).order_by('-created_at').first()
if latest_transaction:
return latest_transaction.status
return None
def get_last_lesson_id(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
user = request.user
# Use prefetched lessons if available
if hasattr(obj, 'lessons') and obj.lessons.all():
lessons = [lesson for lesson in obj.lessons.all() if lesson.is_active]
completed_lessons = []
# Check which lessons are completed using prefetched data
for lesson in lessons:
if hasattr(lesson, 'completions') and lesson.completions.all():
if any(completion.student_id == user.id for completion in lesson.completions.all()):
completed_lessons.append(lesson)
if completed_lessons:
# Find the last completed lesson by priority
last_completed = max(completed_lessons, key=lambda x: x.priority)
# Find next lesson
next_lessons = [l for l in lessons if l.priority > last_completed.priority]
if next_lessons:
return min(next_lessons, key=lambda x: x.priority).id
# If no completed lessons or no next lesson, return first lesson
if lessons:
return min(lessons, key=lambda x: x.priority).id
# Fallback to direct queries if not prefetched
last_completed_lesson = LessonCompletion.objects.filter(
student=user,
course_lesson__course=obj
).order_by('-completed_at').first()
if last_completed_lesson:
next_lesson = CourseLesson.objects.filter(
course=obj,
priority__gt=last_completed_lesson.course_lesson.priority,
is_active=True
).order_by('priority').first()
if not next_lesson:
next_lesson = CourseLesson.objects.filter(
course=obj,
is_active=True
).order_by('priority').first()
if next_lesson:
return next_lesson.id
return None
def get_access(self, obj):
if user := self._get_authenticated_user():
return self._has_access(user, obj)
return False
def get_professor(self, obj):
"""Return the course professor's profile using UserProfileSerializer"""
if obj.professor:
return UserProfileSerializer(obj.professor, context=self.context).data
return None
def get_is_professor(self, obj):
if professor := self._get_authenticated_user():
return obj.professor == professor
return False
def get_lessons_count(self, obj):
# Use prefetched lessons if available
if hasattr(obj, 'lessons') and obj.lessons.all():
lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active)
return max(lessons_count, obj.lessons_count)
# Fallback to direct query
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
completed_count = self._get_completed_lessons_count(student, obj)
# Ensure completed count doesn't exceed total lessons count
total_lessons = self.get_lessons_count(obj)
return min(completed_count, total_lessons)
return None
def _has_access(self, user, course):
"""
Check if the user has access to the course content.
Access is granted if:
1. User is the professor of the course.
2. User is staff (admin).
3. User is an active participant in the course.
"""
if user.is_staff or user.is_superuser:
return True
if course.professor_id == user.id:
return True
return Participant.objects.filter(
student_id=user.id,
course=course,
is_active=True
).exists()
def _is_participant(self, student, course):
"""Deprecated: use _has_access instead. Kept for backward compatibility if needed."""
return self._has_access(student, course)
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."""
# Use prefetched completions if available
if hasattr(course, 'lessons') and course.lessons.all():
completed_count = 0
for lesson in course.lessons.all():
if hasattr(lesson, 'completions') and lesson.completions.all():
if any(completion.student_id == student.id for completion in lesson.completions.all()):
completed_count += 1
return completed_count
# Fallback to direct query if not prefetched
return LessonCompletion.objects.filter(
student=student,
course_lesson__course=course
).count()
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_participant_count(self, obj):
# Use prefetched participants if available
if hasattr(obj, 'participants') and obj.participants.all():
return len(obj.participants.all())
# Fallback to direct query
return obj.participants.count()
def get_price(self, obj):
if obj.is_free or obj.price == 0:
return "0.00"
return str(obj.price)
def get_discount_percentage(self, obj):
if obj.is_free or obj.price == 0:
return 0
return obj.discount_percentage
def get_final_price(self, obj):
if obj.is_free or obj.price == 0:
return "0.00"
return str(obj.final_price)
def get_is_free(self, obj):
return obj.is_free or obj.price == 0
class MyCourseListSerializer(serializers.ModelSerializer):
category = CourseCategorySerializer()
thumbnail = serializers.SerializerMethodField()
lessons_count = 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_count(self, obj):
"""Get the actual count of active lessons"""
# Use prefetched lessons if available
if hasattr(obj, 'lessons') and obj.lessons.all():
lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active)
return max(lessons_count, obj.lessons_count)
# Fallback to direct query
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
completed_count = self._get_completed_lessons_count(student, obj)
# Ensure completed count doesn't exceed total lessons count
total_lessons = self.get_lessons_count(obj)
return min(completed_count, total_lessons)
return None
def _is_participant(self, student, course):
"""Helper method to check if a student is a participant in the given course."""
if student.is_staff or student.is_superuser:
return True
# اگر کاربر استاد دوره است، دسترسی کامل دارد
if course.professor_id == student.id:
return True
# در غیر این صورت چک می‌کنیم که آیا participant است یا خیر
return Participant.objects.filter(
student_id=student.id,
course=course,
is_active=True
).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."""
# Use prefetched completions if available
if hasattr(course, 'lessons') and course.lessons.all():
completed_count = 0
for lesson in course.lessons.all():
if hasattr(lesson, 'completions') and lesson.completions.all():
if any(completion.student_id == student.id for completion in lesson.completions.all()):
completed_count += 1
return completed_count
# Fallback to direct query if not prefetched
return LessonCompletion.objects.filter(
student=student,
course_lesson__course=course
).count()
class AttachmentSerializer(serializers.ModelSerializer):
class Meta:
model = Attachment
fields = ['id', 'title', 'file', 'file_size']
class CourseAttachmentSerializer(serializers.ModelSerializer):
title = serializers.CharField(source='attachment.title', read_only=True)
file = serializers.FileField(source='attachment.file', read_only=True)
file_size = serializers.IntegerField(source='attachment.file_size', read_only=True)
class Meta:
model = CourseAttachment
fields = ['id', 'title', 'file', 'file_size']
class GlossarySerializer(serializers.ModelSerializer):
class Meta:
model = Glossary
fields = ['id', 'title', 'description']
class CourseGlossarySerializer(serializers.ModelSerializer):
title = serializers.CharField(source='glossary.title', read_only=True)
description = serializers.CharField(source='glossary.description', read_only=True)
class Meta:
model = CourseGlossary
fields = ['id', 'title', 'description']

85
apps/course/serializers/lesson.py

@ -1,85 +0,0 @@
from rest_framework import serializers
from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion
from apps.quiz.serializers import QuizListSerializer
class LessonSerializer(serializers.ModelSerializer):
class Meta:
model = Lesson
fields = ['id', 'title', 'content_type', 'content_file', 'video_link', 'duration']
class CourseLessonSerializer(serializers.ModelSerializer):
is_complated = serializers.SerializerMethodField()
quizs = serializers.SerializerMethodField()
permission = serializers.SerializerMethodField()
content_type = serializers.CharField(source='lesson.content_type', read_only=True)
content_file = serializers.FileField(source='lesson.content_file', read_only=True)
video_link = serializers.CharField(source='lesson.video_link', read_only=True)
duration = serializers.IntegerField(source='lesson.duration', read_only=True)
class Meta:
model = CourseLesson
fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs']
def get_permission(self, obj):
if user := self._get_authenticated_user():
return self._has_access(user, obj.course)
return False
def _has_access(self, user, course):
"""
Check if the user has access to the course content.
"""
if user.is_staff or user.is_superuser:
return True
if course.professor_id == user.id:
return True
return Participant.objects.filter(
student_id=user.id,
course=course,
is_active=True
).exists()
def _is_participant(self, student, course):
"""Deprecated: use _has_access instead."""
return self._has_access(student, course)
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_is_complated(self, obj):
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return False
user = request.user
# Use prefetched completions if available
if hasattr(obj, 'completions') and obj.completions.all():
return any(completion.student_id == user.id for completion in obj.completions.all())
# Fallback to direct queries
is_participant = Participant.objects.filter(
student=user,
course=obj.course
).exists()
if not is_participant:
return False
return LessonCompletion.objects.filter(
student=user,
course_lesson=obj
).exists()
def get_quizs(self, obj):
# Now quizzes are directly related to CourseLesson
# print(f'--> type:{type(obj)} obj:{obj.quizzes.all()}')
quizzes = obj.quizzes.all() if hasattr(obj, 'quizzes') else []
if quizzes:
return QuizListSerializer(quizzes, many=True, context=self.context).data
return None

67
apps/course/serializers/online.py

@ -1,67 +0,0 @@
from rest_framework import serializers
from utils import FileFieldSerializer
class OnlineClassTokenCreateSerializer(serializers.Serializer):
redirect_path = serializers.CharField(required=False)
def validate_redirect_path(self, value: str) -> str:
value = value.strip()
if value and value.startswith("http"):
raise serializers.ValidationError("Redirect path must be relative to the frontend domain.")
return value
class OnlineClassTokenVerifySerializer(serializers.Serializer):
token = serializers.CharField(max_length=128)
def validate_token(self, value: str) -> str:
value = value.strip()
if not value:
raise serializers.ValidationError("Token is required.")
return value
class LiveSessionRoomCreateSerializer(serializers.Serializer):
room_id = serializers.CharField(required=False, max_length=255, allow_blank=True)
subject = serializers.CharField(required=False, max_length=255, allow_blank=True)
def validate_room_id(self, value: str) -> str:
return value.strip()
def validate_subject(self, value: str) -> str:
return value.strip()
class LiveSessionTokenSerializer(serializers.Serializer):
course_slug = serializers.CharField(max_length=255)
def validate_course_slug(self, value: str) -> str:
value = value.strip()
if not value:
raise serializers.ValidationError("course_slug is required.")
return value
class LiveSessionRecordedFileSerializer(serializers.Serializer):
recorded_file = serializers.FileField(required=True)
def validate_recorded_file(self, value):
if not value:
raise serializers.ValidationError("recorded_file is required.")
return value
class LiveSessionRecordingSerializer(serializers.Serializer):
file = FileFieldSerializer(required=True)
title = serializers.CharField(required=False, max_length=255, allow_blank=True)
recording_type = serializers.ChoiceField(choices=['voice', 'video'], required=False, default='video')
file_time = serializers.DurationField(required=False, allow_null=True)
def validate_file(self, value):
if not value:
raise serializers.ValidationError("file is required.")
return value
def validate_title(self, value):
return value.strip() if value else None

17
apps/course/serializers/participant.py

@ -1,17 +0,0 @@
from rest_framework import serializers
from apps.course.models import Lesson, Participant, LessonCompletion
from apps.account.models import StudentUser, User
class ParticipantSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True)
gender = serializers.ChoiceField(choices=User.GenderChoices.choices, required=True)
class Meta:
model = StudentUser
fields = ['fullname' , 'phone_number', 'gender', 'email', 'birthdate']

29
apps/course/serializers/professor.py

@ -1,29 +0,0 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.account.serializers import UserProfileSerializer
from utils import FileFieldSerializer, absolute_url
User = get_user_model()
class ProfessorListSerializer(serializers.ModelSerializer):
course_count = serializers.IntegerField(read_only=True)
lesson_count = serializers.IntegerField(read_only=True)
avatar = FileFieldSerializer(required=False)
class Meta:
model = User
fields = ['id', 'slug', 'fullname', 'avatar','experience_years', 'course_count', 'lesson_count']
class ProfessorDetailSerializer(UserProfileSerializer):
course_count = serializers.IntegerField(read_only=True)
lesson_count = serializers.IntegerField(read_only=True)
experience_years = serializers.IntegerField(read_only=True)
slug = serializers.CharField(read_only=True)
class Meta(UserProfileSerializer.Meta):
fields = UserProfileSerializer.Meta.fields + ['slug', 'experience_years', 'course_count', 'lesson_count']
read_only_fields = list(set(UserProfileSerializer.Meta.read_only_fields + ['slug', 'experience_years', 'course_count', 'lesson_count']))

3
apps/course/services/__init__.py

@ -1,3 +0,0 @@
from .plugnmeet import PlugNMeetClient, PlugNMeetError
__all__ = ['PlugNMeetClient', 'PlugNMeetError']

1660
apps/course/services/api.md
File diff suppressed because it is too large
View File

151
apps/course/services/plugnmeet.py

@ -1,151 +0,0 @@
import json
import hmac
import hashlib
from typing import Any, Dict, Optional
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class PlugNMeetError(Exception):
def __init__(self, message: str, *, status_code: Optional[int] = None, response_data: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.status_code = status_code
self.response_data = response_data or {}
class PlugNMeetClient:
def __init__(self, *, base_url: Optional[str] = None, api_key: Optional[str] = None, api_secret: Optional[str] = None, timeout: Optional[float] = None):
self.base_url = (base_url or getattr(settings, "PLUGNMEET_SERVER_URL", "")).rstrip("/")
self.api_key = api_key or getattr(settings, "PLUGNMEET_API_KEY", "")
self.api_secret = api_secret or getattr(settings, "PLUGNMEET_API_SECRET", "")
self.timeout = timeout or getattr(settings, "PLUGNMEET_TIMEOUT", 10.0)
if not self.base_url or not self.api_key or not self.api_secret:
raise ImproperlyConfigured("PlugNMeet integration settings are incomplete.")
def create_room(self, payload: Dict[str, Any]) -> Dict[str, Any]:
# Convert entire payload keys to camelCase as required by PlugNMeet protocol
print(f"[PlugNMeet] Creating room with payload: {payload}")
prepared = self._camelize_dict(payload)
return self._post("/auth/room/create", prepared)
def get_join_token(self, payload: Dict[str, Any]) -> Dict[str, Any]:
# Convert entire payload keys to camelCase as required by PlugNMeet protocol
prepared = self._camelize_dict(payload)
return self._post("/auth/room/getJoinToken", prepared)
def is_room_active(self, room_id: str) -> Dict[str, Any]:
return self._post("/auth/room/isRoomActive", {"roomId": room_id})
def get_recording_info(self, record_id: str) -> Dict[str, Any]:
"""Get detailed information about a recording."""
return self._post("/auth/recording/recordingInfo", {"recordId": record_id})
def get_recording_download_token(self, record_id: str) -> Dict[str, Any]:
"""Get a temporary download token for a recording."""
return self._post("/auth/recording/getDownloadToken", {"recordId": record_id})
def download_file(self, download_path: str, save_to: str) -> bool:
"""
Download a file from PlugNMeet server.
Args:
download_path: The download path (e.g., '/download/recording/token_xxx')
save_to: Local file path to save the downloaded file
Returns:
True if download successful, False otherwise
"""
import logging
logger = logging.getLogger(__name__)
url = urljoin(f"{self.base_url}/", download_path.lstrip("/"))
logger.info(f"[PlugNMeet] Downloading file from {url}")
try:
response = requests.get(url, stream=True, timeout=300) # 5 minute timeout for large files
response.raise_for_status()
# Write file in chunks
with open(save_to, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
logger.info(f"[PlugNMeet] File downloaded successfully to {save_to}")
return True
except requests.RequestException as exc:
logger.error(f"[PlugNMeet] Failed to download file - error={str(exc)}")
raise PlugNMeetError(f"Failed to download file: {str(exc)}") from exc
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
headers = {
"Content-Type": "application/json",
"API-KEY": self.api_key,
"HASH-SIGNATURE": self._build_signature(body),
}
import logging
logger = logging.getLogger(__name__)
logger.debug(f"[PlugNMeet] POST {path} - Body: {body[:500]}")
try:
response = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=self.timeout)
except requests.RequestException as exc:
raise PlugNMeetError("Failed to reach PlugNMeet server.") from exc
if response.status_code >= 400:
response_data = self._safe_json(response)
raise PlugNMeetError(
"PlugNMeet server returned an error.",
status_code=response.status_code,
response_data=response_data,
)
data = self._safe_json(response)
if data is None:
raise PlugNMeetError("PlugNMeet server returned an invalid response format.")
if isinstance(data, dict) and data.get('status') is False:
error_message = data.get('msg') or data.get('message') or "PlugNMeet operation failed."
raise PlugNMeetError(
error_message,
status_code=response.status_code,
response_data=data,
)
return data
@staticmethod
def _snake_to_camel(key: str) -> str:
parts = key.split("_")
if not parts:
return key
return parts[0] + "".join(p.capitalize() or "" for p in parts[1:])
def _camelize_dict(self, obj: Any) -> Any:
if isinstance(obj, dict):
return {self._snake_to_camel(k): self._camelize_dict(v) for k, v in obj.items()}
if isinstance(obj, list):
return [self._camelize_dict(v) for v in obj]
return obj
def _build_signature(self, body: str) -> str:
digest = hmac.new(self.api_secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256)
return digest.hexdigest()
@staticmethod
def _safe_json(response: requests.Response) -> Optional[Dict[str, Any]]:
try:
return response.json()
except ValueError:
return None
__all__ = ["PlugNMeetClient", "PlugNMeetError"]

82
apps/course/signals.py

@ -1,82 +0,0 @@
from apps.course.models import Course
from apps.chat.models import RoomMessage
from django.db.models import Q
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from django.contrib.auth import get_user_model
UserModel = get_user_model()
@receiver(post_save, sender=Course)
def handle_room_message_for_course(sender, instance, created, **kwargs):
if created: # فقط برای موارد جدید اجرا شود
RoomMessage.objects.create(
name=f"{instance.title} - Group",
description=f"Group chat for course: {instance.title}",
initiator=instance.professor, # استاد به‌عنوان سازنده اتاق
course=instance,
room_type=RoomMessage.RoomTypeChoices.GROUP
)
else: # این بخش در زمان آپدیت دوره اجرا می‌شود
# Find the existing group room for this course and update its details
RoomMessage.objects.filter(
course=instance,
room_type=RoomMessage.RoomTypeChoices.GROUP
).update(
name=f"{instance.title} - Group",
description=f"Group chat for course: {instance.title}",
initiator=instance.professor
)
@receiver(post_save, sender=Course)
def ensure_professor_role(sender, instance, **kwargs):
professor = getattr(instance, 'professor', None)
if professor:
professor.ensure_professor_profile()
@receiver([post_save, post_delete], sender=Course)
def invalidate_professor_course_cache(sender, instance, **kwargs):
"""
Clears the cached professor detail page AND their course list
whenever a course assigned to them is created, updated, or deleted.
"""
if instance.professor:
detail_cache_key = f"professor_detail_{instance.professor.slug}"
cache.delete(detail_cache_key)
# Optional: If you update a Professor's profile in the admin directly
@receiver([post_save, post_delete], sender=UserModel)
def invalidate_professor_profile_cache(sender, instance, **kwargs):
if instance.user_type == UserModel.UserType.PROFESSOR:
cache_key = f"professor_detail_{instance.slug}"
cache.delete(cache_key)
@receiver(post_save, sender=Course)
def sync_course_chat_locks(sender, instance, **kwargs):
"""
Automatically locks/unlocks the related chat rooms when the admin
toggles the chat locks on the Course page.
"""
# 1. Update the Group Chat
RoomMessage.objects.filter(
course=instance,
room_type=RoomMessage.RoomTypeChoices.GROUP
).update(is_locked=instance.is_group_chat_locked)
# 2. Update the Private Chats between the Professor and Students of this course
# Get all student IDs enrolled in this course
student_ids = instance.participants.values_list('student_id', flat=True)
if student_ids:
RoomMessage.objects.filter(
room_type=RoomMessage.RoomTypeChoices.PRIVATE
).filter(
# Find rooms where initiator is prof and recipient is student, OR vice versa
Q(initiator_id=instance.professor_id, recipient_id__in=student_ids) |
Q(initiator_id__in=student_ids, recipient_id=instance.professor_id)
).update(is_locked=instance.is_professor_chat_locked)

29
apps/course/templates/course/add_student_form.html

@ -1,29 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n unfold %}
{% block breadcrumbs %}{% endblock %}
{% block extrahead %}
{{ block.super }}
<script src="{% url 'admin:jsi18n' %}"></script>
{{ form.media }}
{% endblock %}
{% block content %}
<form action="" method="post" novalidate>
<div class="aligned border border-base-200 mb-8 rounded-md pt-3 px-3 shadow-sm dark:border-base-800">
{% csrf_token %}
{% for field in form %}
{% include "unfold/helpers/field.html" with field=field %}
{% endfor %}
</div>
<div class="flex justify-end">
{% component "unfold/components/button.html" with submit=1 %}
{% trans "Submit form" %}
{% endcomponent %}
</div>
</form>
{% endblock %}

3
apps/course/tests.py

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

1
apps/course/tests/__init__.py

@ -1 +0,0 @@

182
apps/course/tests/test_live_session_api.py

@ -1,182 +0,0 @@
import tempfile
from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from apps.account.models import ProfessorUser, StudentUser
from apps.course.models import (
Course,
CourseCategory,
CourseLiveSession,
Participant,
)
@override_settings(
PLUGNMEET_SERVER_URL='https://meet.example.com',
PLUGNMEET_API_KEY='test-key',
PLUGNMEET_API_SECRET='test-secret',
MEDIA_ROOT=tempfile.gettempdir(),
)
class CourseLiveSessionAPITests(APITestCase):
def setUp(self):
self.professor = ProfessorUser.objects.create(
email='prof@example.com',
fullname='Professor Sample',
experience_years=5,
)
self.student = StudentUser.objects.create(
email='student@example.com',
fullname='Student Sample',
)
self.category = CourseCategory.objects.create(name='Category', slug='category')
thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
self.course = Course.objects.create(
title='Sample Course',
slug='sample-course',
category=self.category,
professor=self.professor,
thumbnail=thumbnail,
video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
video_link='https://example.com/video',
is_online=True,
online_link='https://example.com/live',
level=Course.LevelChoices.BEGINNER,
duration=10,
lessons_count=2,
description='Description',
short_description='Short',
status=Course.StatusChoices.ONGOING,
is_free=True,
)
professor_avatar = SimpleUploadedFile('prof-avatar.jpg', b'avatar', content_type='image/jpeg')
self.professor.avatar = professor_avatar
self.professor.save(update_fields=['avatar'])
student_avatar = SimpleUploadedFile('student-avatar.jpg', b'avatar', content_type='image/jpeg')
self.student.avatar = student_avatar
self.student.save(update_fields=['avatar'])
@mock.patch('apps.course.views.live_session.PlugNMeetClient')
def test_professor_can_create_room(self, mock_client_cls):
mock_client = mock_client_cls.return_value
mock_client.create_room.return_value = {'status': 'success'}
self.client.force_authenticate(user=self.professor)
url = reverse('course-live-session-room-create', kwargs={'slug': self.course.slug})
payload = {
'room_id': 'custom-room-id',
'subject': 'Algebra Session',
}
response = self.client.post(url, payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mock_client.create_room.assert_called_once()
self.assertTrue(
CourseLiveSession.objects.filter(course=self.course, room_id='custom-room-id').exists()
)
@mock.patch('apps.course.views.live_session.PlugNMeetClient')
def test_professor_receives_admin_token(self, mock_client_cls):
mock_client = mock_client_cls.return_value
mock_client.get_join_token.return_value = {'token': 'abc123'}
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session',
started_at=timezone.now(),
room_id='room-123',
)
self.client.force_authenticate(user=self.professor)
url = reverse('course-live-session-token')
response = self.client.post(url, {'room_id': session.room_id}, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
args, _ = mock_client.get_join_token.call_args
payload = args[0]
self.assertTrue(payload['user_info']['is_admin'])
profile_pic = payload['user_info']['user_metadata'].get('profilePic')
self.assertEqual(profile_pic, f"http://testserver{self.professor.avatar.url}")
self.assertEqual(response.data['token'], 'abc123')
@mock.patch('apps.course.views.live_session.PlugNMeetClient')
def test_student_participant_receives_limited_token(self, mock_client_cls):
mock_client = mock_client_cls.return_value
mock_client.get_join_token.return_value = {'token': 'student-token'}
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session',
started_at=timezone.now(),
room_id='room-456',
)
Participant.objects.create(course=self.course, student=self.student)
self.client.force_authenticate(user=self.student)
url = reverse('course-live-session-token')
response = self.client.post(url, {'room_id': session.room_id}, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
args, _ = mock_client.get_join_token.call_args
payload = args[0]
self.assertFalse(payload['user_info']['is_admin'])
metadata = payload['user_info']['user_metadata']
self.assertIn('lock_microphone', metadata['lock_settings'])
self.assertEqual(metadata.get('profilePic'), f"http://testserver{self.student.avatar.url}")
self.assertEqual(response.data['token'], 'student-token')
def test_student_without_access_cannot_get_token(self):
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session',
started_at=timezone.now(),
room_id='room-789',
)
self.client.force_authenticate(user=self.student)
url = reverse('course-live-session-token')
response = self.client.post(url, {'room_id': session.room_id}, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_validate_metadata_includes_active_room_for_student(self):
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session Live',
started_at=timezone.now(),
room_id='room-live-1',
)
Participant.objects.create(course=self.course, student=self.student)
self.client.force_authenticate(user=self.student)
url = reverse('course-online-validate', kwargs={'slug': self.course.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
metadata = response.data['metadata']
self.assertTrue(metadata['is_online'])
self.assertEqual(metadata['active_room_id'], session.room_id)
self.assertTrue(metadata['can_join_live_session'])
self.assertEqual(metadata['live_session']['room_id'], session.room_id)
self.assertIsNotNone(metadata['live_session']['started_at'])
def test_validate_metadata_for_professor_hides_creation_when_online(self):
CourseLiveSession.objects.create(
course=self.course,
subject='Session Live',
started_at=timezone.now(),
room_id='room-live-2',
)
self.client.force_authenticate(user=self.professor)
url = reverse('course-online-validate', kwargs={'slug': self.course.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
metadata = response.data['metadata']
self.assertFalse(metadata['can_create_live_session'])

216
apps/course/tests/test_multiple_roles_api.py

@ -1,216 +0,0 @@
"""
تستهای API برای سیستم نقشهای چندگانه
"""
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth.models import Group
from apps.account.models import User
from apps.course.models import Course, CourseCategory, Participant
from apps.transaction.models import TransactionParticipant
class MultipleRolesAPITestCase(TestCase):
def setUp(self):
"""راه‌اندازی داده‌های تست"""
# ایجاد گروه‌ها
Group.objects.create(name="Professor Group")
Group.objects.create(name="Student Group")
Group.objects.create(name="Client Group")
# ایجاد کاربر
self.user = User.objects.create_user(
email='test@example.com',
fullname='Test User',
password='testpass123'
)
# ایجاد دسته‌بندی دوره
self.category = CourseCategory.objects.create(
name='Test Category',
slug='test-category'
)
# راه‌اندازی API client
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_user_profile_basic_functionality(self):
"""تست عملکرد اصلی profile کاربر"""
# اضافه کردن نقش‌ها
self.user.add_role('professor')
self.user.add_role('student')
# تست متدهای جدید User model
self.assertTrue(self.user.has_role('professor'))
self.assertTrue(self.user.has_role('student'))
roles = self.user.get_all_roles()
self.assertIn('professor', roles)
self.assertIn('student', roles)
# نقش اصلی باید professor باشد (اولویت بالاتر)
self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR)
def test_course_access_for_professor(self):
"""تست دسترسی استاد به دوره خودش"""
# کاربر استاد می‌شود و دوره می‌سازد
self.user.add_role('professor')
course = Course.objects.create(
title='Test Course',
slug='test-course',
category=self.category,
professor=self.user,
level='beginner',
duration=10,
lessons_count=5,
description='Test description'
)
# تست serializer
from apps.course.serializers import CourseDetailSerializer
# شبیه‌سازی request context
from django.test import RequestFactory
factory = RequestFactory()
request = factory.get('/')
request.user = self.user
serializer = CourseDetailSerializer(course, context={'request': request})
data = serializer.data
# استاد باید دسترسی داشته باشد
self.assertTrue(data['access'])
def test_course_enrollment_preserves_professor_role(self):
"""تست اینکه ثبت‌نام در دوره نقش professor را حفظ می‌کند"""
# کاربر استاد می‌شود
self.user.add_role('professor')
# کاربر دیگری دوره می‌سازد
other_user = User.objects.create_user(
email='other@example.com',
fullname='Other User',
password='testpass123'
)
other_user.add_role('professor')
course = Course.objects.create(
title='Test Course',
slug='test-course',
category=self.category,
professor=other_user,
level='beginner',
duration=10,
lessons_count=5,
description='Test description',
is_free=True
)
# شبیه‌سازی transaction
transaction_data = {
'participant_infos': [{'email': self.user.email}]
}
# شبیه‌سازی منطق transaction
if not self.user.has_role('student'):
self.user.add_role('student')
Participant.objects.create(
student=self.user,
course=course
)
# بررسی اینکه هر دو نقش حفظ شده‌اند
self.assertTrue(self.user.has_role('professor'))
self.assertTrue(self.user.has_role('student'))
# بررسی اینکه کاربر می‌تواند دوره خودش را مدیریت کند
own_course = Course.objects.create(
title='Own Course',
slug='own-course',
category=self.category,
professor=self.user,
level='beginner',
duration=10,
lessons_count=5,
description='Own course description'
)
self.assertTrue(self.user.can_manage_course(own_course))
self.assertFalse(self.user.can_manage_course(course)) # دوره دیگری
def test_course_access_for_professor_student(self):
"""تست دسترسی دوره برای کاربری که هم استاد و هم دانش‌آموز است"""
# کاربر استاد می‌شود
self.user.add_role('professor')
# دوره خودش
own_course = Course.objects.create(
title='Own Course',
slug='own-course',
category=self.category,
professor=self.user,
level='beginner',
duration=10,
lessons_count=5,
description='Own course description'
)
# دوره دیگری
other_user = User.objects.create_user(
email='other@example.com',
fullname='Other User',
password='testpass123'
)
other_user.add_role('professor')
other_course = Course.objects.create(
title='Other Course',
slug='other-course',
category=self.category,
professor=other_user,
level='beginner',
duration=10,
lessons_count=5,
description='Other course description'
)
# کاربر در دوره دیگری شرکت می‌کند
self.user.add_role('student')
Participant.objects.create(
student=self.user,
course=other_course
)
# تست دسترسی‌ها
from apps.course.serializers import CourseDetailSerializer
from django.test import RequestFactory
factory = RequestFactory()
request = factory.get('/')
request.user = self.user
# دسترسی به دوره خودش
serializer = CourseDetailSerializer(own_course, context={'request': request})
data = serializer.data
self.assertTrue(data['access'])
# دسترسی به دوره دیگری (به عنوان participant)
serializer = CourseDetailSerializer(other_course, context={'request': request})
data = serializer.data
self.assertTrue(data['access'])
def test_backward_compatibility(self):
"""تست سازگاری با کدهای قدیمی"""
# property قدیمی باید همچنان کار کند
self.user.add_role('student')
self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT)
self.user.add_role('professor')
self.assertEqual(self.user.user_type_based_on_groups, User.UserType.PROFESSOR)
# user_type field باید بروزرسانی شود
self.assertEqual(self.user.user_type, User.UserType.PROFESSOR)

113
apps/course/tests/test_professor_api.py

@ -1,113 +0,0 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from rest_framework.test import APITestCase
from apps.account.models import ProfessorUser
from apps.course.models import Course, CourseCategory, CourseLesson, Lesson
class TestProfessorAPI(APITestCase):
def setUp(self):
self.professor = ProfessorUser.objects.create(
email='professor@example.com',
fullname='استاد نمونه',
experience_years=7,
)
self.category = CourseCategory.objects.create(name='General', slug='general')
thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
self.course = Course.objects.create(
title='Test Course',
slug='test-course',
category=self.category,
professor=self.professor,
thumbnail=thumbnail,
video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
video_link='https://example.com/video',
is_online=True,
online_link='https://example.com/classroom',
level=Course.LevelChoices.BEGINNER,
duration=10,
lessons_count=1,
description='Sample description',
short_description='Short description',
status=Course.StatusChoices.ONGOING,
is_free=True,
)
lesson = Lesson.objects.create(
title='Lesson 1',
content_type=Lesson.ContentTypeChoices.VIDEO_FILE,
duration=5,
)
CourseLesson.objects.create(course=self.course, lesson=lesson, priority=1, is_active=True)
self.professor.refresh_from_db()
def test_professor_list_api(self):
url = reverse('course-professor-list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)
item = response.data['results'][0]
self.assertEqual(item['slug'], self.professor.slug)
self.assertEqual(item['fullname'], self.professor.fullname)
self.assertEqual(item['experience_years'], 7)
self.assertEqual(item['course_count'], 1)
self.assertEqual(item['lesson_count'], 1)
def test_professor_detail_api(self):
url = reverse('course-professor-detail', kwargs={'slug': self.professor.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['slug'], self.professor.slug)
self.assertEqual(data['fullname'], self.professor.fullname)
self.assertEqual(data['experience_years'], 7)
self.assertEqual(data['course_count'], 1)
self.assertEqual(data['lesson_count'], 1)
def test_professor_courses_api(self):
url = reverse('course-professor-course-list', kwargs={'slug': self.professor.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)
course_data = response.data['results'][0]
self.assertEqual(course_data['id'], self.course.id)
self.assertEqual(course_data['title'], self.course.title)
def test_professor_slug_generated_without_fullname(self):
professor = ProfessorUser.objects.create(
email='slugless@example.com',
fullname='',
)
self.assertTrue(professor.slug)
def test_course_creation_promotes_professor_user(self):
professor = ProfessorUser.objects.create(
email='pending@example.com',
fullname='کاربر موقت',
)
professor.user_type = ProfessorUser.UserType.CLIENT
professor.slug = None
professor.save(update_fields=['user_type', 'slug'])
thumbnail = SimpleUploadedFile('thumb2.jpg', b'filecontent', content_type='image/jpeg')
Course.objects.create(
title='Auto Promote Course',
slug='auto-promote-course',
category=self.category,
professor=professor,
thumbnail=thumbnail,
video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
video_link='https://example.com/video2',
is_online=False,
level=Course.LevelChoices.BEGINNER,
duration=5,
lessons_count=0,
description='Test',
short_description='Test',
status=Course.StatusChoices.REGISTERING,
is_free=True,
)
professor.refresh_from_db()
self.assertEqual(professor.user_type, ProfessorUser.UserType.PROFESSOR)
self.assertTrue(professor.slug)

312
apps/course/token-join-guide.md

@ -1,312 +0,0 @@
# راهنمای گرفتن توکن و ورود کلاینت به کلاس‌های plugNmeet
این راهنما خلاصه می‌کند که برای سناریوی استاد/دانشجو چگونه از سرویس plugNmeet توکن بگیریم و کلاینت فرانت‌اند (`client/`) با آن وارد کلاس شود.
## پیش‌نیازها
- آدرس سرویس: `window.PLUG_N_MEET_SERVER_URL = "https://meet.newhorizonco.uk"` (در `config.js`).
- `api_key` و `secret` از فایل پیکربندی بک‌اند (`services/plugnmeet-server/config.yaml`).
- بدنهٔ درخواست‌ها باید با پروتکل JSON متناظر با پیام‌های پروتوباف (`plugnmeet-protocol`) ارسال شود؛ سرور طبق `HandleAuthHeaderCheck` هدرهای امنیتی را بررسی می‌کند.
## گام ۱: ایجاد یا فعال بودن اتاق
### API Endpoint برای Django Backend:
```
POST /api/courses/<course-slug>/online/room/create/
```
### بدنه درخواست از فرانت به Django:
```json
{
"subject": "کلاس جبر فصل ۱" // اختیاری - عنوان روم
}
```
**⚠️ نکات مهم:**
- **فرانت نباید `metadata` ارسال کند!**
- بک‌اند Django (در `apps/course/views/live_session.py`) به‌طور خودکار تنظیمات امنیتی را اعمال می‌کند
- این تضمین می‌کند که تنظیمات امنیتی به‌صورت متمرکز و یکسان اعمال شود
### بدنه درخواست از Django به PlugNMeet (خودکار):
بک‌اند Django این بدنه را خودش به PlugNMeet ارسال می‌کند:
```json
{
"room_id": "algebra-1402",
"metadata": {
"room_title": "کلاس جبر فصل ۱",
"default_lock_settings": {
"lock_microphone": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند
"lock_webcam": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند
"lock_screen_sharing": true // 🔒 قفل - فقط میزبان می‌تواند باز کند
},
"room_features": {
"mute_on_start": true, // 🔇 همه با میک خاموش وارد می‌شوند
"waiting_room_features": {
"is_active": false
}
}
}
}
```
> **چرا بک‌اند این کار را می‌کند؟**
> - ✅ **امنیت متمرکز**: تنظیمات امنیتی در یک جا کنترل می‌شود
> - ✅ **جلوگیری از دستکاری**: فرانت نمی‌تواند تنظیمات را تغییر دهد
> - ✅ **یکپارچگی**: همه کلاس‌ها با تنظیمات یکسان ساخته می‌شوند
> - 🔒 طبق تابع `AssignLockSettingsToUser` در `pkg/models/user_lock.go` این مقادیر برای کاربران غیر-admin اعمال می‌شود
## گام ۲: گرفتن توکن ورود
### API Endpoint برای Django Backend:
```
POST /api/courses/online/room/token/
```
### درخواست از فرانت به Django:
```
Headers:
Authorization: Token <USER_TOKEN>
Content-Type: application/json
Body:
{
"course_slug": "algebra-10"
}
```
**⚠️ نکات مهم:**
- **فرانت فقط `course_slug` ارسال می‌کند!**
- بک‌اند Django از `Authorization` header کاربر را شناسایی می‌کند
- بک‌اند خودش live session فعال دوره را پیدا می‌کند:
```python
# 1. پیدا کردن دوره
course = Course.objects.get(slug=course_slug)
# 2. پیدا کردن live session فعال
session = CourseLiveSession.objects.get(
course=course,
ended_at__isnull=True # session هایی که هنوز به پایان نرسیده‌اند
)
# 3. گرفتن room_id
room_id = session.room_id
```
- بک‌اند خودش همه اطلاعات کاربر را می‌سازد:
- `user_id` از `request.user`
- `name` از `user.get_full_name()` یا `user.email`
- `is_admin` از `user.can_manage_course(course)`
- `profilePic` از `user.avatar`
- `lock_settings` برای غیر-admin
### بدنه درخواست از Django به PlugNMeet (خودکار):
بک‌اند Django این payload را خودش می‌سازد و به PlugNMeet می‌فرستد:
**برای استاد:**
```json
{
"room_id": "algebra-1402",
"user_info": {
"user_id": "10", // 🔐 از request.user
"name": "استاد نمونه", // 🔐 از user.get_full_name()
"is_admin": true, // 🔐 از user.can_manage_course()
"user_metadata": {
"is_hidden": false,
"profilePic": "https://..." // 🔐 از user.avatar
}
}
}
```
**برای دانشجو:**
```json
{
"room_id": "algebra-1402",
"user_info": {
"user_id": "27", // 🔐 از request.user
"name": "دانشجو نمونه", // 🔐 از user.get_full_name()
"is_admin": false, // 🔐 از user.can_manage_course()
"user_metadata": {
"profilePic": "https://...", // 🔐 از user.avatar
"lock_settings": { // 🔒 خودکار برای غیر-admin
"lock_microphone": true,
"lock_screen_sharing": true,
"lock_webcam": true
}
}
}
}
```
### نحوه کار بک‌اند Django:
```python
# 1. شناسایی کاربر از token
user = request.user # از Authorization header
# 2. پیدا کردن دوره و session فعال
course = Course.objects.get(slug=course_slug)
session = CourseLiveSession.objects.get(course=course, ended_at__isnull=True)
room_id = session.room_id
# 3. تشخیص نقش
is_admin = user.can_manage_course(course) # استاد یا مالک دوره
# 4. ساخت user_info
user_info = {
'user_id': str(user.id),
'name': user.get_full_name() or user.email,
'is_admin': is_admin,
}
# 4. اضافه کردن profilePic
profile_pic = request.build_absolute_uri(user.avatar.url)
user_metadata['profilePic'] = profile_pic
# 5. اضافه کردن lock_settings برای غیر-admin
if not is_admin:
user_metadata['lock_settings'] = {
'lock_microphone': True,
'lock_screen_sharing': True,
'lock_webcam': True,
}
```
### ارسال به PlugNMeet:
بک‌اند Django با هدرهای امنیتی به PlugNMeet ارسال می‌کند:
- `API-KEY`: از settings
- `HASH-SIGNATURE`: `HMAC_SHA256(body, secret)`
- این توکن JWT اختصاصی plugNmeet است که در `GeneratePNMJoinToken` ساخته می‌شود
- `is_admin: true` باعث می‌شود در `GetPNMJoinToken` کاربر به عنوان presenter با تمام دسترسی‌ها ثبت شود
- `lock_settings` باعث می‌شود در فرانت‌اند PlugNMeet دکمه‌های میکروفون/وبکم غیرفعال شوند
### پاسخ Django به فرانت:
```json
{
"room_id": "algebra-1402",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"plugnmeet": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires": 300,
...
}
}
```
فرانت با این `token` می‌تواند کاربر را به PlugNMeet وارد کند:
```
https://meet.newhorizonco.uk/?access_token=<TOKEN>
```
## گام ۳: ورود کلاینت با توکن
۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئری‌استرینگ یا از کوکی `pnm_access_token` می‌خواند (`getAccessToken` در `client/src/helpers/utils.ts`).
۲. آدرس ورود: `https://meet.newhorizonco.uk/?access_token=<TOKEN>`.
۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری:
- درخواست `POST /api/verifyToken` را با هدر `Authorization: <TOKEN>` می‌فرستد (`HandleVerifyToken`).
- اگر توکن معتبر باشد، لیست آدرس‌های NATS و موضوعات لازم را می‌گیرد و اتصال را آغاز می‌کند (`startNatsConn`).
۴. پس از اتصال، وضعیت کاربر و اتاق در Redux ذخیره می‌شود (`sessionSlice`). اگر کاربر ادمین باشد، تمام امکانات بدون محدودیت فعال است؛ در غیر این صورت مقدارهای `lock_settings` تعیین می‌کنند چه دکمه‌هایی فعال باشند.
## کنترل حالت صحبت/شنیدن برای استاد و دانشجو
### استاد (Moderator/Host):
- ✅ در توکن `is_admin: true` ارسال می‌شود
- ✅ بک‌اند Django در `apps/course/views/live_session.py` این را تشخیص می‌دهد:
```python
is_admin = user.can_manage_course(course) # استاد یا مالک دوره
```
- ✅ سرور PlugNMeet در `GetPNMJoinToken` رول presenter را فعال می‌کند
- ✅ **هیچ قفلی** روی میکروفون، وبکم یا اشتراک صفحه اعمال نمی‌شود
- 🎤 استاد می‌تواند بلافاصله صحبت کند و به دانشجو **اجازه صحبت** دهد
### دانشجو (Participant):
- 🔒 در توکن `is_admin: false` ارسال می‌شود
- 🔒 بک‌اند Django خودکار lock_settings را اضافه می‌کند:
```python
if not is_admin:
user_metadata['lock_settings'] = {
'lock_microphone': True,
'lock_screen_sharing': True,
'lock_webcam': True,
}
```
- 🔇 دکمه‌های میکروفون، وبکم و اشتراک صفحه **غیرفعال** هستند
- 👂 فقط می‌تواند **گوش دهد** تا میزبان اجازه دهد
- این منطق در `joinModal.tsx` با متغیر `isMicLock` پیاده‌سازی شده است
### نحوه دادن اجازه به دانشجو:
- میزبان باید از داخل کلاس از طریق UI کنترل کند
- یا از API `/api/updateLockSettings` یا `switchPresenter` استفاده کند
## نکات تکمیلی
### توکن‌ها و انقضا:
- توکن‌ها زمان انقضای مفهومی دارند (`client.token_validity` در YAML)
- در صورت نزدیک شدن به انقضا، کلاینت خودکار با `REQ_RENEW_PNM_TOKEN` درخواست تمدید می‌دهد
### Authorization:
- برای درخواست‌های بعدی به `/api/...` همان هدر `Authorization` را ست کنید
- کلاینت این کار را در `helpers/api/plugNmeetAPI.ts` انجام می‌دهد
### مدیریت دسترسی‌ها:
- اگر می‌خواهید دانشجو را به صحبت‌کننده ارتقا دهید: `/api/updateLockSettings` یا `switchPresenter`
- این کار فقط توسط **میزبان** امکان‌پذیر است
## 🔐 جمع‌بندی امنیت
### ❌ چیزهایی که فرانت نباید انجام دهد:
#### موقع ساخت روم:
- ❌ ارسال `metadata`
- ❌ ارسال `default_lock_settings`
- ❌ ارسال `room_features`
#### موقع گرفتن توکن:
- ❌ ارسال `room_id` (بک‌اند خودش از session فعال می‌گیرد)
- ❌ ارسال `user_info`
- ❌ ارسال `is_admin`
- ❌ ارسال `lock_settings`
- ❌ ارسال `user_id` یا `name`
### ✅ چیزهایی که فرانت فقط ارسال می‌کند:
#### موقع ساخت روم:
```json
{
"room_id": "algebra-1402", // اختیاری
"subject": "کلاس جبر" // اختیاری
}
```
#### موقع گرفتن توکن:
```json
{
"course_slug": "algebra-10" // فقط این!
}
```
+ `Authorization: Token <USER_TOKEN>` در header
### ✅ چیزهایی که بک‌اند Django خودش انجام می‌دهد:
#### برای همه درخواست‌ها:
- ✅ شناسایی کاربر از `Authorization` header
- ✅ بررسی دسترسی با `user.can_manage_course()` یا `Participant.objects.filter()`
#### موقع ساخت روم:
- ✅ تعیین `default_lock_settings` (همه `true`)
- ✅ تعیین `room_features.mute_on_start: true`
- ✅ ساخت `metadata` کامل برای PlugNMeet
#### موقع گرفتن توکن:
- ✅ پیدا کردن live session فعال از `course_slug`
- ✅ گرفتن `room_id` از session
- ✅ ساخت `user_id` از `request.user.id`
- ✅ ساخت `name` از `user.get_full_name()` یا `user.email`
- ✅ تشخیص `is_admin` از `user.can_manage_course(course)`
- ✅ گرفتن `profilePic` از `user.avatar`
- ✅ اضافه کردن `lock_settings` برای غیر-admin
- ✅ ساخت `user_info` کامل برای PlugNMeet
**نتیجه:**
- 🔒 **امنیت کامل**: فرانت نمی‌تواند هیچ تنظیمات امنیتی را دستکاری کند
- ✅ **متمرکز**: همه logic در بک‌اند Django است
- 🎯 **ساده**: فرانت فقط `course_slug` و `Authorization` header ارسال می‌کند
- 🔐 **قابل کنترل**: بک‌اند تعیین می‌کند کدام session فعال است

37
apps/course/urls.py

@ -1,37 +0,0 @@
from django.urls import path, re_path
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('lesson/completion/', views.LessonCompletionToggleAPIView.as_view(), name='lesson-completion'),
path('professors/', views.ProfessorListAPIView.as_view(), name='course-professor-list'),
re_path(r'professors/(?P<slug>[\w-]+)/courses/$', views.ProfessorCourseListAPIView.as_view(), name='course-professor-course-list'),
re_path(r'professors/(?P<slug>[\w-]+)/$', views.ProfessorDetailAPIView.as_view(), name='course-professor-detail'),
path('<int:pk>/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'),
re_path(r'(?P<slug>[\w-]+)/online/validate/$', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-validate'),
path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'),
re_path(r'(?P<slug>[\w-]+)/online/room/create/$', views.CourseLiveSessionRoomCreateAPIView.as_view(), name='course-live-session-room-create'),
path('online/room/token/', views.CourseLiveSessionTokenAPIView.as_view(), name='course-live-session-token'),
path('<int:course_id>/live-sessions/recorded-file/', views.CourseLiveSessionRecordedFileAPIView.as_view(), name='course-live-session-recorded-file'),
# PlugNMeet webhook endpoint
path('plugnmeet/webhook/', views.PlugNMeetWebhookAPIView.as_view(), name='plugnmeet-webhook'),
re_path(r'(?P<slug>[\w-]+)/$', views.CourseDetailAPIView.as_view(), name='course-detail'),
re_path(r'(?P<slug>[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),
re_path(r'(?P<slug>[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'),
re_path(r'(?P<slug>[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'),
path('lesson/<int:id>/', views.LessonDetailView.as_view(), name='lesson-detail'),
re_path(r'(?P<slug>[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'),
# path('<slug:slug>/participant/join/', views.ParticipantCreateView.as_view(), name='course-participant-join'),
]

6
apps/course/views/__init__.py

@ -1,6 +0,0 @@
from .course import *
from .lesson import *
from .participant import *
from .professor import *
from .live_session import *
from .webhook import *

822
apps/course/views/course.py

@ -1,822 +0,0 @@
from django.conf import settings
import logging
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, F
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from utils.pagination import StandardResultsSetPagination
logger = logging.getLogger(__name__)
from apps.course.serializers import (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer,
OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer
)
from apps.course.models import (
Course,
CourseAttachment,
CourseCategory,
CourseGlossary,
CourseLiveSession,
LiveSessionUser,
Participant,
)
from apps.course.doc import *
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
from apps.account.serializers import UserProfileSerializer
from utils.exceptions import AppAPIException
from utils.redis import OnlineClassTokenManager
UserModel = get_user_model()
class CourseCategoryAPIView(ListAPIView):
queryset = CourseCategory.objects.all()
serializer_class = CourseCategorySerializer
pagination_class = StandardResultsSetPagination
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description=doc_course_category(),
tags=["Imam-Javad - Course"]
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
from utils.pagination import StandardResultsSetPagination
class CourseListAPIView(ListAPIView):
serializer_class = CourseListSerializer
filter_backends = [SearchFilter]
search_fields = ['title', 'category__name', 'professor__fullname']
pagination_class = StandardResultsSetPagination
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
operation_description=doc_course_list(),
manual_parameters=[
openapi.Parameter(
'search', openapi.IN_QUERY,
description="Search by course title, category name, or professor's full name",
type=openapi.TYPE_STRING,
),
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 <bool>",
type=openapi.TYPE_BOOLEAN,
),
openapi.Parameter(
'is_online', openapi.IN_QUERY,
description="Статус участия is_online <bool>",
type=openapi.TYPE_BOOLEAN,
),
])
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related for ForeignKey relationships and filtering
"""
queryset = Course.objects.select_related(
'category',
'professor'
).exclude(status=Course.StatusChoices.INACTIVE)
request = self.request
filters = request.query_params
# Handle category_slug with multiple values separated by commas
if category_slugs := filters.get('category_slug'):
category_slugs_list = category_slugs.split(',')
queryset = queryset.filter(category__slug__in=category_slugs_list)
# Handle status with multiple values separated by commas
if statuses := filters.get('status'):
statuses_list = statuses.split(',')
queryset = queryset.filter(status__in=statuses_list)
if is_free := filters.get('is_free'):
is_free = is_free.lower() == 'true'
queryset = queryset.filter(
Q(is_free=is_free) | Q(price=0) if is_free else 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
class CourseDetailAPIView(RetrieveAPIView):
serializer_class = CourseDetailSerializer
lookup_field = "slug"
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
tags=["Imam-Javad - Course"],
operation_description="Get detailed information about a specific course",
responses={
200: openapi.Response(
description="Course details",
schema=CourseDetailSerializer()
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related and prefetch_related for all relationships
"""
return Course.objects.select_related(
'category',
'professor'
).prefetch_related(
'lessons__lesson',
'lessons__completions',
'attachments__attachment',
'glossaries__glossary',
'participants__student',
'room_messages'
)
@swagger_auto_schema(
operation_description=doc_course_detail(),
tags=['Imam-Javad - Course'],
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
from rest_framework.authentication import TokenAuthentication
class MyCourseListAPIView(ListAPIView):
serializer_class = MyCourseListSerializer
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(manual_parameters=[
openapi.Parameter(
'completed', openapi.IN_QUERY,
description="мои курсы completed <bool> true",
type=openapi.TYPE_BOOLEAN,
),
openapi.Parameter(
'certificate', openapi.IN_QUERY,
type=openapi.TYPE_BOOLEAN,
),
],
operation_description=doc_courses_my_courses(),
operation_summary="Home",
tags=['Imam-Javad - Course']
)
def get(self, request, *args, **kwargs):
print(f'--> my-course-> {request}/ {kwargs}')
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset for user's courses (as student and professor) with select_related and prefetch_related
"""
queryset = Course.objects.select_related(
'category',
'professor'
).prefetch_related(
'lessons__lesson',
'lessons__completions',
'participants__student'
).exclude(status=Course.StatusChoices.INACTIVE)
request = self.request
filters = request.query_params
user = self.request.user
# Include courses where user is a student OR the professor
qs = queryset.filter(Q(participants__student=user) | Q(professor=user)).distinct()
completed_only = filters.get('completed', '').lower() == 'true'
if completed_only == True:
# نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند
qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=user),
distinct=True
)
).filter(total_lessons=F('completed_lessons'))
elif completed_only == False:
# نمایش دوره‌هایی که همه درس‌هایشان تکمیل نشده‌اند
qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=user),
distinct=True
)
).filter(total_lessons__gt=F('completed_lessons'))
if 'completed' not in filters:
certificate = filters.get('certificate', '').lower() == 'true'
if certificate:
qs = qs.exclude(
course_certificates__student=user,
course_certificates__status__in=['pending', 'approved']
)
return qs
class AttachmentListAPIView(ListAPIView):
serializer_class = CourseAttachmentSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
manual_parameters=[
openapi.Parameter(
'slug', openapi.IN_PATH,
description="Slug of the Course",
type=openapi.TYPE_STRING,
required=True
)
],
operation_description="Retrieve a list of attachments for a given course by its slug."
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related for attachment relationship
"""
course_slug = self.kwargs.get('slug')
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
raise NotFound("Course not found")
return CourseAttachment.objects.select_related(
'course',
'attachment'
).filter(course=course)
class GlossaryListAPIView(ListAPIView):
serializer_class = CourseGlossarySerializer
filter_backends = [SearchFilter]
search_fields = ['glossary__title', 'glossary__description']
pagination_class = StandardResultsSetPagination
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description="Get glossary terms for a specific course",
tags=["Imam-Javad - Course"],
manual_parameters=[
openapi.Parameter(
'slug', openapi.IN_PATH,
description="Course slug",
type=openapi.TYPE_STRING,
required=True
),
openapi.Parameter(
'search', openapi.IN_QUERY,
description="Search in glossary title or description",
type=openapi.TYPE_STRING,
required=False
)
],
responses={
200: openapi.Response(
description="List of glossary terms",
schema=CourseGlossarySerializer(many=True)
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related for glossary relationship
"""
course_slug = self.kwargs.get('slug')
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
raise NotFound("Course not found")
return CourseGlossary.objects.select_related(
'course',
'glossary'
).filter(course=course)
class CourseOnlineClassTokenAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
serializer_class = OnlineClassTokenCreateSerializer
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
operation_description="Generate a temporary entry token for an online class.",
request_body=OnlineClassTokenCreateSerializer,
responses={
status.HTTP_201_CREATED: openapi.Response(
description="Token generated successfully.",
examples={
"application/json": {
"token": "abc123xyz789...",
"url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics",
"expires_in": 300,
}
}
)
}
)
def post(self, request, pk, *args, **kwargs):
serializer = self.get_serializer(data=request.data or {})
serializer.is_valid(raise_exception=True)
course = get_object_or_404(Course, pk=pk)
if not course.is_online:
raise AppAPIException({'message': "Course is not marked as online."}, status_code=status.HTTP_400_BAD_REQUEST)
if not self._user_has_access(request.user, course):
raise AppAPIException({'message': "You do not have access to this course."}, status_code=status.HTTP_403_FORBIDDEN)
manager = OnlineClassTokenManager()
user_token, _ = Token.objects.get_or_create(user=request.user)
identifier = f"{request.user.id}:{user_token.key[:8]}"
token = manager.generate_token(course_id=course.id, user_identifier=identifier)
manager.store_token(token, {
'course_id': course.id,
'user_id': request.user.id,
'user_token': user_token.key,
'course_slug': course.slug,
'extra': {
'professor_in_class': False,
},
})
# ساخت URL ثابت با token و course slug
entry_url = f"https://imamjavad.newhorizonco.uk/join-class?token={token}&slug={course.slug}"
return Response({
'token': token,
'url': entry_url,
'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300),
}, status=status.HTTP_201_CREATED)
@staticmethod
def _user_has_access(user, course: Course) -> bool:
if user.is_staff or course.professor_id == user.id:
return True
return Participant.objects.filter(course=course, student=user).exists()
class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
# Changed from AllowAny to enable DRF authentication
# Users can still access without auth, but if token is provided, it will be authenticated
authentication_classes = [TokenAuthentication]
permission_classes = [AllowAny]
serializer_class = OnlineClassTokenVerifySerializer
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
operation_description="Get course and user data for authenticated user.",
manual_parameters=[
openapi.Parameter(
'slug', openapi.IN_PATH,
description="Course Slug",
type=openapi.TYPE_STRING,
required=True
)
],
responses={
status.HTTP_200_OK: openapi.Response(
description="Course data retrieved.",
examples={
"application/json": {
"course": {"id": 1, "title": "Sample Course"},
"user": {"id": 10, "fullname": "John Doe"},
"metadata": {
"status": "ongoing",
"has_started": True,
"professor_in_class": False,
"validated_at": "2024-01-01T10:00:00Z"
}
}
}
)
}
)
def get(self, request, slug, *args, **kwargs):
print("=" * 80)
print(f"[Online Validate GET] REQUEST RECEIVED {request.data}")
print(f"[Online Validate GET] slug={slug}")
print(f"[Online Validate GET] user={request.user}")
print(f"[Online Validate GET] user.is_authenticated={request.user.is_authenticated}")
print(f"[Online Validate GET] user.id={request.user.id if request.user.is_authenticated else 'N/A'}")
print("=" * 80)
logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}")
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, slug=slug)
user = request.user
print(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}")
logger.info(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}")
# DEPRECATED: Polling approach replaced by webhook integration
# Room status is now updated automatically via PlugNMeet webhooks
# self._sync_room_status_with_plugnmeet(course)
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(
course,
{'user_id': user.id if user.is_authenticated else None, 'extra': {}, 'generated_at': timezone.now().isoformat()},
user=user,
)
print(f"[Online Validate GET] Success - metadata={metadata}")
logger.info(f"[Online Validate GET] Success - user_id={user.id if user.is_authenticated else 'anonymous'} course={slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}")
return Response({
'course': course_data,
'user': user_data,
'metadata': metadata,
}, status=status.HTTP_200_OK)
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
operation_description="Validate an online class entry token and return course/user data.",
request_body=OnlineClassTokenVerifySerializer,
responses={
status.HTTP_200_OK: openapi.Response(
description="Token validated.",
examples={
"application/json": {
"course": {"id": 1, "title": "Sample Course"},
"user": {"id": 10, "fullname": "John Doe"},
"metadata": {
"status": "ongoing",
"has_started": True,
"professor_in_class": False,
"validated_at": "2024-01-01T10:00:00Z"
}
}
}
)
}
)
def post(self, request, *args, **kwargs):
print("=" * 80)
print(f"[Online Validate POST] REQUEST RECEIVED")
print(f"[Online Validate POST] request.data={request.data}")
print(f"[Online Validate POST] has_token={'token' in request.data}")
print("=" * 80)
logger.info(f"[Online Validate POST] Request received - has_token={'token' in request.data}")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
print(f"[Online Validate POST] Token extracted - token={token_value[:16]}...")
logger.info(f"[Online Validate POST] Token extracted - token={token_value[:16]}...")
manager = OnlineClassTokenManager()
try:
payload = manager.get_payload(token_value)
print(f"[Online Validate POST] Token decoded successfully - payload={payload}")
logger.info(f"[Online Validate POST] Token decoded successfully - payload={payload}")
except Exception as e:
print(f"[Online Validate POST] Token decode FAILED - error={str(e)} type={type(e).__name__}")
logger.error(f"[Online Validate POST] Token decode failed - error={str(e)} type={type(e).__name__}")
raise
course_id = payload.get('course_id')
user_id = payload.get('user_id')
if not course_id or not user_id:
print(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}")
logger.warning(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}")
raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST)
print(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}")
logger.info(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}")
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, pk=course_id)
user = get_object_or_404(UserModel.objects.all(), pk=user_id)
print(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}")
logger.info(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}")
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, payload, user=user)
print(f"[Online Validate POST] Success - metadata={metadata}")
logger.info(f"[Online Validate POST] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}")
return Response({
'course': course_data,
'user': user_data,
'metadata': metadata,
}, status=status.HTTP_200_OK)
def _build_metadata(self, course: Course, payload: dict, user=None) -> dict:
status_value = course.status
has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED]
timing_data = course.timing if isinstance(course.timing, dict) else {}
user = user or UserModel.objects.filter(pk=payload.get('user_id')).first()
user_id = getattr(user, 'id', None)
can_manage = bool(user and user.can_manage_course(course))
live_context = self._build_live_session_context(course)
can_join_live_session = live_context['is_online'] and self._user_can_join_live_session(user, course)
logger.debug(f"[Online Validate Metadata] user_id={user_id} course={course.slug} can_manage={can_manage} is_online={live_context['is_online']} can_join={can_join_live_session}")
metadata = {
'status': status_value,
'has_started': has_started,
'has_finished': status_value == Course.StatusChoices.FINISHED,
'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
'can_create_live_session': can_manage and not live_context['is_online'],
'can_join_live_session': can_join_live_session,
'scheduled_times': timing_data,
'generated_at': payload.get('generated_at'),
'validated_at': timezone.now().isoformat(),
'redirect_path': payload.get('redirect_path'),
}
metadata.update(live_context)
return metadata
def _build_live_session_context(self, course: Course) -> dict:
"""
Build live session context with real-time PlugNMeet verification.
This method:
1. Finds the latest session for the course
2. Verifies with PlugNMeet if the room is actually active
3. Auto-closes sessions if PlugNMeet reports room is inactive
4. Returns accurate session state independent of webhook delays
"""
latest_session = (
CourseLiveSession.objects.filter(course=course)
.order_by('-started_at', '-id')
.first()
)
if not latest_session:
logger.debug(f"[Live Session Context] No session found for course={course.slug}")
return {
'is_online': False,
'live_session': None,
'active_room_id': None,
'livesession_started_at': None,
'livesession_ended_at': None,
}
started_at = latest_session.started_at
ended_at = latest_session.ended_at
is_online = bool(started_at and not ended_at)
# CRITICAL: Verify room status with PlugNMeet if session appears online
# This ensures we don't rely solely on webhooks which may fail or be delayed
if is_online and latest_session.room_id:
is_online = self._verify_and_sync_room_status(latest_session)
# Refresh ended_at in case session was closed
ended_at = latest_session.ended_at
live_session_data = {
'id': latest_session.id,
'room_id': latest_session.room_id,
'subject': latest_session.subject,
'started_at': self._format_datetime(started_at),
'ended_at': self._format_datetime(ended_at),
}
context = {
'is_online': is_online,
'live_session': live_session_data,
'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None,
'livesession_started_at': live_session_data['started_at'],
'livesession_ended_at': live_session_data['ended_at'],
}
logger.debug(f"[Live Session Context] course={course.slug} is_online={is_online} room_id={live_session_data['room_id']}")
return context
def _verify_and_sync_room_status(self, session: CourseLiveSession) -> bool:
"""
Verify room status with PlugNMeet and sync local database.
Args:
session: The CourseLiveSession to verify
Returns:
bool: True if room is active, False if inactive or verification failed
Side effects:
- Closes session in database if PlugNMeet reports room is inactive
- Updates LiveSessionUser records accordingly
"""
if not session.room_id:
logger.warning(f"[Room Sync] Session has no room_id - session_id={session.id}")
return False
try:
client = PlugNMeetClient()
response = client.is_room_active(session.room_id)
# Debug: Log full response to understand structure
logger.debug(f"[Room Sync] PlugNMeet response - room_id={session.room_id} response={response}")
# PlugNMeet returns: {"status": true, "msg": "...", "isActive": true/false}
# Note: isActive might be boolean or string, handle both
is_active_raw = response.get('isActive', False)
is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true'
response_msg = response.get('msg', 'unknown')
response_status = response.get('status', False)
# Additional check: if status is true and msg says "active", trust that
if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower():
is_active = True
if is_active:
logger.debug(f"[Room Sync] ✓ Room verified active - room_id={session.room_id} session_id={session.id} msg={response_msg}")
return True
else:
# Room is not active in PlugNMeet but active in our database
# This happens when:
# 1. Webhook failed to fire
# 2. Room was ended externally
# 3. Room crashed or timed out
logger.warning(f"[Room Sync] ✗ Room inactive in PlugNMeet - auto-closing session_id={session.id} room_id={session.room_id} msg={response_msg}")
self._close_live_session(session)
return False
except PlugNMeetError as e:
# PlugNMeet API returned an error
error_msg = str(e)
logger.error(f"[Room Sync] PlugNMeet API error - room_id={session.room_id} session_id={session.id} error={error_msg}")
# Check if error message indicates room doesn't exist
if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower():
logger.warning(f"[Room Sync] Room not found in PlugNMeet - closing session_id={session.id}")
self._close_live_session(session)
return False
# For other API errors, assume room might still be active (fail-safe)
logger.warning(f"[Room Sync] Cannot verify room status, assuming inactive for safety - room_id={session.room_id}")
return False
except Exception as e:
# Network error or unexpected exception
logger.error(f"[Room Sync] Unexpected error verifying room - room_id={session.room_id} session_id={session.id} error={type(e).__name__}: {str(e)}")
# For network errors, fail-safe: assume room might still be active
# but log a warning for monitoring
logger.warning(f"[Room Sync] Network/system error, assuming room inactive for safety")
return False
@staticmethod
def _user_can_join_live_session(user, course: Course) -> bool:
if not user or not user.is_authenticated:
return False
if user.is_staff or user.is_superuser or user.can_manage_course(course):
return True
return Participant.objects.filter(
course=course,
student_id=user.id,
is_active=True
).exists()
@staticmethod
def _format_datetime(value):
if not value:
return None
if isinstance(value, str):
return value
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value).isoformat()
# DEPRECATED: This polling approach is inefficient and has been replaced by webhook integration
# def _sync_room_status_with_plugnmeet(self, course: Course):
# """
# Check if active live session's room is still active in PlugNMeet.
# If room is inactive, close the session and all related user entries.
#
# DEPRECATED: This should be replaced by webhook integration.
# PlugNMeet now sends webhooks when rooms end, eliminating the need for polling.
# """
# active_session = CourseLiveSession.objects.filter(
# course=course,
# ended_at__isnull=True
# ).first()
#
# if not active_session or not active_session.room_id:
# return
#
# try:
# client = PlugNMeetClient()
# response = client.is_room_active(active_session.room_id)
# is_active = response.get('isActive', False)
#
# if not is_active:
# logger.info(f"[Room Sync] Room inactive in PlugNMeet - room_id={active_session.room_id} session_id={active_session.id}")
# self._close_live_session(active_session)
# else:
# logger.debug(f"[Room Sync] Room still active - room_id={active_session.room_id} session_id={active_session.id}")
#
# except (PlugNMeetError, Exception) as e:
# logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}")
def _close_live_session(self, session: CourseLiveSession):
"""
Close a live session and all related user entries.
Sets ended_at for session and exited_at/is_online for users.
"""
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
logger.info(f"[Room Sync] Session closed - session_id={session.id} room_id={session.room_id} ended_at={now}")
updated_count = LiveSessionUser.objects.filter(
session=session,
is_online=True,
exited_at__isnull=True
).update(
is_online=False,
exited_at=now,
updated_at=now
)
if updated_count > 0:
logger.info(f"[Room Sync] User sessions closed - session_id={session.id} count={updated_count}")

168
apps/course/views/lesson.py

@ -1,168 +0,0 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView, GenericAPIView
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.authentication import TokenAuthentication
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from apps.course.serializers import (
CourseLessonSerializer
)
from apps.course.models import Course, CourseLesson, LessonCompletion
from apps.course.doc import *
from utils.exceptions import AppAPIException
from utils.pagination import StandardResultsSetPagination
from rest_framework.permissions import IsAuthenticated
class LessonListView(ListAPIView):
serializer_class = CourseLessonSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description=doc_courses_lesson(),
tags=['Imam-Javad - Course'],
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related and prefetch_related for lesson relationships
"""
course_slug = self.kwargs.get('slug')
course = get_object_or_404(Course, slug=course_slug)
return CourseLesson.objects.select_related(
'course',
'lesson'
).prefetch_related(
'completions',
'quizzes'
).filter(
course=course,
is_active=True
).order_by('priority', 'id')
class LessonDetailView(RetrieveAPIView):
serializer_class = CourseLessonSerializer
permission_classes = [AllowAny]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description="Get detailed lesson information with navigation data",
tags=["Imam-Javad - Course"],
manual_parameters=[
openapi.Parameter(
'id', openapi.IN_PATH,
description="Lesson ID",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={
200: openapi.Response(
description="Lesson details with navigation information",
schema=CourseLessonSerializer()
)
}
)
def get(self, request, *args, **kwargs):
"""
Optimized lesson detail view with select_related for relationships
"""
lesson_id = self.kwargs.get('id')
course_lesson = get_object_or_404(
CourseLesson.objects.select_related('course', 'lesson'),
id=lesson_id,
is_active=True
)
course = course_lesson.course
lessons = CourseLesson.objects.select_related(
'lesson'
).filter(
course=course,
is_active=True
).order_by('priority')
total_lessons = lessons.count()
current_lesson_number = list(lessons.values_list('id', flat=True)).index(course_lesson.id) + 1
next_lesson = lessons.filter(priority__gt=course_lesson.priority).order_by('priority').first()
next_lesson_id = next_lesson.id if next_lesson else None
previous_lesson = lessons.filter(priority__lt=course_lesson.priority).order_by('-priority').first()
previous_lesson_id = previous_lesson.id if previous_lesson else None
lesson_data = self.get_serializer(course_lesson).data
lesson_data['total_lessons'] = total_lessons
lesson_data['current_lesson_number'] = current_lesson_number
lesson_data['next_lesson_id'] = next_lesson_id
lesson_data['previous_lesson_id'] = previous_lesson_id
lesson_data['can_go_next'] = next_lesson is not None
return Response(lesson_data)
class LessonCompletionToggleAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description="Toggle lesson completion status (Check/Uncheck)",
tags=["Imam-Javad - Course"],
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['lesson_id'],
properties={
'lesson_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the lesson to toggle'),
},
),
responses={
201: 'Lesson marked as COMPLETED.',
200: 'Lesson marked as INCOMPLETE (Unchecked).',
400: 'Lesson ID is required.',
404: 'Lesson not found.',
}
)
def post(self, request):
student = request.user
lesson_id = request.data.get('lesson_id')
if not lesson_id:
return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
course_lesson = CourseLesson.objects.get(id=lesson_id)
except CourseLesson.DoesNotExist:
return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND)
# TOGGLE LOGIC
# Try to find an existing completion record
completion = LessonCompletion.objects.filter(student=student, course_lesson=course_lesson).first()
if completion:
# Scenario: The user clicked by mistake or wants to un-check
# Action: Delete the record
completion.delete()
return Response(
{'message': 'Lesson marked as incomplete.', 'is_completed': False},
status=status.HTTP_200_OK
)
else:
# Scenario: The lesson is not finished yet
# Action: Create the record
LessonCompletion.objects.create(student=student, course_lesson=course_lesson)
return Response(
{'message': 'Lesson completed successfully.', 'is_completed': True},
status=status.HTTP_201_CREATED
)

661
apps/course/views/live_session.py

@ -1,661 +0,0 @@
import logging
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import TokenAuthentication
from rest_framework.response import Response
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
import time
import jwt
from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording
from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
from utils.exceptions import AppAPIException
from django.conf import settings
logger = logging.getLogger(__name__)
class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
serializer_class = LiveSessionRoomCreateSerializer
@swagger_auto_schema(
operation_description="Create a live session room for a course",
tags=["Imam-Javad - Course"],
manual_parameters=[
openapi.Parameter(
'slug', openapi.IN_PATH,
description="Course slug",
type=openapi.TYPE_STRING,
required=True
)
],
responses={
201: openapi.Response(
description="Live session room created successfully"
)
}
)
def post(self, request, slug, *args, **kwargs):
# 1. Standard Permissions Logic
course = get_object_or_404(Course, slug=slug)
if not request.user.can_manage_course(course):
raise AppAPIException({'message': 'Permission denied'}, status_code=403)
# 2. Setup ID and Metadata
room_id = f"room-{course.id}-imamjavad"
subject = f"{course.title} Live Session"
# 3. Database Logic - Check FIRST before calling PlugNMeet
# Strategy:
# 1. Try to find active session (ended_at is NULL)
# 2. If not found, try to find ended session with same room_id and reactivate it
# 3. If not found, create new session
session = None
needs_room_creation = False
try:
# Try to get active session first
session = CourseLiveSession.objects.get(
course=course, room_id=room_id, ended_at__isnull=True
)
needs_room_creation = False
logger.info(f"[LiveSession Create] Found active session - session_id={session.id} room_id={room_id}")
except CourseLiveSession.DoesNotExist:
# No active session, check if there's an old one with same room_id
try:
session = CourseLiveSession.objects.get(
course=course, room_id=room_id
)
# Reactivate the old session and mark for room recreation
session.ended_at = None
session.started_at = timezone.now()
session.subject = subject
session.save(update_fields=['ended_at', 'started_at', 'subject', 'updated_at'])
needs_room_creation = True
logger.info(f"[LiveSession Create] Reactivated ended session - session_id={session.id} room_id={room_id}")
except CourseLiveSession.DoesNotExist:
# No session exists at all, create new one and mark for room creation
session = CourseLiveSession.objects.create(
course=course,
room_id=room_id,
subject=subject,
started_at=timezone.now()
)
needs_room_creation = True
logger.info(f"[LiveSession Create] Created new session - session_id={session.id} room_id={room_id}")
# 4. Create room in PlugNMeet ONLY if needed
if needs_room_creation:
metadata = self._build_metadata(subject)
try:
client = PlugNMeetClient()
plugnmeet_response = client.create_room({
'room_id': room_id,
'empty_timeout':90,
'metadata': metadata,
})
logger.info(f"[LiveSession Create] Room created in PlugNMeet - room_id={room_id}")
except Exception as exc:
logger.error(f"[LiveSession Create] PlugNMeet Error: {exc}")
# If room creation fails, revert the session changes
if session.ended_at is None:
session.ended_at = timezone.now()
session.save(update_fields=['ended_at', 'updated_at'])
raise AppAPIException({'message': f'Failed to create room: {str(exc)}'}, status_code=500)
else:
logger.info(f"[LiveSession Create] Skipping room creation - room already exists - room_id={room_id}")
# 5. Generate the JOIN TOKEN (The Entry Ticket)
token_payload = {
"room_id": room_id,
"user_info": {
"name": f"{request.user.first_name} {request.user.last_name}",
"user_id": str(request.user.id),
"is_admin": True,
"is_hidden": False
}
}
pnm_token = jwt.encode(
{
"iss": settings.PLUGNMEET_API_KEY,
"exp": int(time.time()) + 3600,
"sub": str(request.user.id),
**token_payload
},
settings.PLUGNMEET_API_SECRET,
algorithm="HS256"
)
logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} user_id={request.user.id}")
return Response({
'success': True,
'session': {'id': session.id, 'room_id': session.room_id},
'access_token': pnm_token
}, status=201)
# def post(self, request, slug, *args, **kwargs):
# logger.info(f"[LiveSession Create] Request from user_id={request.user.id} for course={slug}")
# data = dict(request.data or {})
# if 'metadata' in data:
# logger.warning("[LiveSession Create] 'metadata' provided by client will be ignored for security reasons.")
# data.pop('metadata', None)
# serializer = self.get_serializer(data=data)
# serializer.is_valid(raise_exception=True)
# course = get_object_or_404(Course, slug=slug)
# if not request.user.can_manage_course(course):
# logger.warning(f"[LiveSession Create] Permission denied - user_id={request.user.id} course={slug}")
# raise AppAPIException({'message': 'You do not have permission to create a live session for this course.'}, status_code=status.HTTP_403_FORBIDDEN)
# logger.info(f"[LiveSession Create] Permission granted for user_id={request.user.id} course={slug}")
# subject = serializer.validated_data.get('subject') or f"{course.title} Live Session"
# room_id = self._build_room_id(course)
# metadata = self._build_metadata(subject)
# payload = {
# 'room_id': room_id,
# 'metadata': metadata,
# }
# logger.info(f"[LiveSession Create] Calling PlugNMeet API - room_id={room_id} course={slug}")
# try:
# client = PlugNMeetClient()
# plugnmeet_response = client.create_room(payload)
# logger.info(f"[LiveSession Create] PlugNMeet room created successfully - room_id={room_id}")
# except ImproperlyConfigured as exc:
# logger.error(f"[LiveSession Create] Configuration error - {str(exc)}")
# raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
# except PlugNMeetError as exc:
# logger.error(f"[LiveSession Create] PlugNMeet API error - room_id={room_id} error={str(exc)}")
# detail = exc.response_data or {'message': str(exc)}
# status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY
# raise AppAPIException(detail, status_code=status_code)
# session, created = CourseLiveSession.objects.get_or_create(
# course=course,
# room_id=room_id,
# defaults={
# 'subject': subject,
# 'started_at': timezone.now(),
# },
# )
# if created:
# logger.info(f"[LiveSession Create] New session created - session_id={session.id} room_id={room_id} course={slug}")
# else:
# logger.info(f"[LiveSession Create] Existing session reactivated - session_id={session.id} room_id={room_id} course={slug}")
# updates = {}
# if session.subject != subject:
# session.subject = subject
# updates['subject'] = subject
# if session.room_id != room_id:
# session.room_id = room_id
# updates['room_id'] = room_id
# if session.started_at is None:
# session.started_at = timezone.now()
# updates['started_at'] = session.started_at
# if updates:
# session.save(update_fields=list(updates.keys()))
# logger.info(f"[LiveSession Create] Session updated - session_id={session.id} fields={list(updates.keys())}")
# logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} course={slug} user_id={request.user.id}")
# return Response({
# 'session': {
# 'id': session.id,
# 'room_id': session.room_id,
# 'subject': session.subject,
# 'started_at': session.started_at,
# },
# 'plugnmeet': plugnmeet_response,
# }, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
@staticmethod
def _build_room_id(course: Course) -> str:
return f"room-{course.id}-imamjavad"
def _build_metadata(self, subject: str) -> dict:
# Build secured, centralized metadata. Client overrides are NOT allowed.
return {
'room_title': subject,
'default_lock_settings': {
'lock_microphone': True,
'lock_webcam': True,
'lock_screen_sharing': True,
'lock_whiteboard': False,
'lock_shared_notepad': False,
'lock_chat': False,
'lock_chat_send_message': False,
'lock_chat_file_share': False,
'lock_private_chat': False,
},
'room_features': {
'allow_webcams': True,
'mute_on_start': True,
'allow_screen_sharing': True,
'allow_recording': True,
'allow_rtmp': False,
'allow_view_other_webcams': True,
'allow_view_other_participants_list': True,
'admin_only_webcams': False,
'allow_polls': True,
'room_duration': 0,
'chat_features': {
'allow_chat': True,
'allow_file_upload': True,
},
'shared_note_pad_features': {
'allowed_shared_note_pad': True,
},
'whiteboard_features': {
'allowed_whiteboard': True,
},
'breakout_room_features': {
'is_allow': True,
'allowed_number_rooms': 6,
},
'waiting_room_features': {
'is_active': False,
},
'recording_features': {
'is_allow': True,
'is_allow_cloud': True,
'is_allow_local': True,
'enable_auto_cloud_recording': False,
'only_record_admin_webcams': False,
},
},
}
def _deep_update(self, base: dict, overrides: dict) -> dict:
for key, value in overrides.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key] = self._deep_update(base.get(key, {}), value)
else:
base[key] = value
return base
class CourseLiveSessionTokenAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
serializer_class = LiveSessionTokenSerializer
@swagger_auto_schema(
operation_description="Generate access token for live session",
tags=["Imam-Javad - Course"],
responses={
200: openapi.Response(
description="Live session token generated successfully"
)
}
)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
course_slug = serializer.validated_data['course_slug']
user = request.user
logger.info(f"[LiveSession Token] Request from user_id={user.id} for course={course_slug}")
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
logger.warning(f"[LiveSession Token] Course not found - course={course_slug} user_id={user.id}")
raise AppAPIException({'message': 'Course not found.'}, status_code=status.HTTP_404_NOT_FOUND)
if not course.is_online:
logger.warning(f"[LiveSession Token] Course not configured for online - course={course_slug} user_id={user.id}")
raise AppAPIException({'message': 'Course is not configured for online sessions.'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
session = CourseLiveSession.objects.select_related('course').get(
course=course,
ended_at__isnull=True
)
logger.info(f"[LiveSession Token] Active session found in DB - session_id={session.id} room_id={session.room_id} course={course_slug}")
except CourseLiveSession.DoesNotExist:
logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}")
raise AppAPIException({'message': 'No active live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND)
# Check user role first to determine permissions
is_admin = user.can_manage_course(course)
user_role = "professor" if is_admin else "student"
logger.info(f"[LiveSession Token] User role determined - user_id={user.id} role={user_role} course={course_slug}")
# CRITICAL: Verify the room is actually active in PlugNMeet before issuing token
# This prevents issuing tokens for rooms that have crashed or ended without webhook notification
room_id = session.room_id
room_is_active = self._verify_room_is_active(session)
if not room_is_active:
# Room is not active in PlugNMeet but we have a session record
if is_admin:
# For professors: Auto-recreate the room in PlugNMeet
logger.info(f"[LiveSession Token] Room inactive but professor requesting - recreating room - room_id={room_id} session_id={session.id}")
try:
self._recreate_room_in_plugnmeet(course, session)
logger.info(f"[LiveSession Token] Room recreated successfully - room_id={room_id}")
except Exception as e:
logger.error(f"[LiveSession Token] Failed to recreate room - room_id={room_id} error={str(e)}")
raise AppAPIException({
'status': 'False',
'message': f'Failed to recreate room: {str(e)}',
'msg': f'Failed to recreate room: {str(e)}'
}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
# For students: Refuse token - they cannot create rooms
logger.error(f"[LiveSession Token] Room not active and user is student - refusing token - room_id={room_id} user_id={user.id}")
raise AppAPIException({
'status': 'False',
'message': 'room is not active. Please wait for the professor to start the class.',
'msg': 'room is not active. Please wait for the professor to start the class.'
}, status_code=status.HTTP_400_BAD_REQUEST)
if not is_admin and not Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists():
logger.warning(f"[LiveSession Token] Access denied - user_id={user.id} not enrolled in course={course_slug}")
raise AppAPIException({'message': 'You do not have access to this live session.'}, status_code=status.HTTP_403_FORBIDDEN)
user_info = {
'user_id': str(user.id),
'name': user.get_full_name() or user.email or user.username or f"user-{user.id}",
'is_admin': is_admin,
}
user_metadata = {}
profile_pic = self._build_profile_url(request, user)
logger.info(f"[LiveSession Token] Profile pic URL - user_id={user.id} url={profile_pic}")
if profile_pic:
user_metadata['profilePic'] = profile_pic
if not is_admin:
user_metadata['lock_settings'] = {
'lock_microphone': True,
'lock_screen_sharing': True,
'lock_webcam': True,
'lock_whiteboard': False,
'lock_shared_notepad': False,
'lock_chat': False,
'lock_chat_send_message': False,
'lock_chat_file_share': False,
'lock_private_chat': False,
}
else:
user_metadata['is_hidden'] = False
if user_metadata:
user_info['user_metadata'] = user_metadata
payload = {
'room_id': room_id,
'user_info': user_info,
}
logger.info(f"[LiveSession Token] Requesting token from PlugNMeet - room_id={room_id} user_id={user.id} role={user_role}")
try:
client = PlugNMeetClient()
plugnmeet_response = client.get_join_token(payload)
logger.info(f"[LiveSession Token] Token generated successfully - room_id={room_id} user_id={user.id}")
except ImproperlyConfigured as exc:
logger.error(f"[LiveSession Token] Configuration error - {str(exc)}")
raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
except PlugNMeetError as exc:
logger.error(f"[LiveSession Token] PlugNMeet API error - room_id={room_id} user_id={user.id} error={str(exc)}")
detail = exc.response_data or {'message': str(exc)}
status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY
raise AppAPIException(detail, status_code=status_code)
logger.info(f"[LiveSession Token] Success - room_id={room_id} user_id={user.id} role={user_role} course={course_slug}")
return Response({
'room_id': room_id,
'token': plugnmeet_response.get('token'),
'plugnmeet': plugnmeet_response,
})
def _build_metadata(self, subject: str) -> dict:
# Build secured, centralized metadata. Client overrides are NOT allowed.
return {
'room_title': subject,
'default_lock_settings': {
'lock_microphone': True,
'lock_webcam': True,
'lock_screen_sharing': True,
'lock_whiteboard': False,
'lock_shared_notepad': False,
'lock_chat': False,
'lock_chat_send_message': False,
'lock_chat_file_share': False,
'lock_private_chat': False,
},
'room_features': {
'allow_webcams': True,
'mute_on_start': True,
'allow_screen_sharing': True,
'allow_recording': True,
'allow_rtmp': False,
'allow_view_other_webcams': True,
'allow_view_other_participants_list': True,
'admin_only_webcams': False,
'allow_polls': True,
'room_duration': 0,
'chat_features': {
'allow_chat': True,
'allow_file_upload': True,
},
'shared_note_pad_features': {
'allowed_shared_note_pad': True,
},
'whiteboard_features': {
'allowed_whiteboard': True,
},
'breakout_room_features': {
'is_allow': True,
'allowed_number_rooms': 6,
},
'waiting_room_features': {
'is_active': False,
},
'recording_features': {
'is_allow': True,
'is_allow_cloud': True,
'is_allow_local': True,
'enable_auto_cloud_recording': False,
'only_record_admin_webcams': False,
},
},
}
@staticmethod
def _verify_room_is_active(session: CourseLiveSession) -> bool:
"""
Verify that the room is actually active in PlugNMeet.
Args:
session: The CourseLiveSession to verify
Returns:
bool: True if room is active in PlugNMeet, False otherwise
Side effects:
- Closes session in database if PlugNMeet reports room is inactive
"""
if not session.room_id:
logger.warning(f"[Room Verify] Session has no room_id - session_id={session.id}")
return False
try:
client = PlugNMeetClient()
response = client.is_room_active(session.room_id)
# Debug: Log full response
logger.debug(f"[Room Verify] PlugNMeet response - room_id={session.room_id} response={response}")
# Handle isActive as boolean or string
is_active_raw = response.get('isActive', False)
is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true'
response_msg = response.get('msg', 'unknown')
response_status = response.get('status', False)
# Trust status and msg if they indicate active room
if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower():
is_active = True
if is_active:
logger.debug(f"[Room Verify] ✓ Room is active - room_id={session.room_id} session_id={session.id}")
return True
else:
logger.warning(f"[Room Verify] ✗ Room is NOT active - room_id={session.room_id} session_id={session.id} msg={response_msg}")
# Auto-close the session since room is not active
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
logger.info(f"[Room Verify] Session auto-closed - session_id={session.id} room_id={session.room_id}")
return False
except PlugNMeetError as e:
error_msg = str(e)
logger.error(f"[Room Verify] PlugNMeet API error - room_id={session.room_id} error={error_msg}")
# If room not found, close the session
if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower():
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
logger.warning(f"[Room Verify] Room not found - session closed - session_id={session.id}")
return False
except Exception as e:
logger.error(f"[Room Verify] Unexpected error - room_id={session.room_id} error={type(e).__name__}: {str(e)}")
return False
def _recreate_room_in_plugnmeet(self, course: Course, session: CourseLiveSession) -> None:
"""
Recreate a room in PlugNMeet when session exists but room is inactive.
This happens when:
- Webhook failed to notify us of room closure
- PlugNMeet server restarted
- Room was manually ended
Args:
course: The course for which to recreate the room
session: The existing session to reactivate
Raises:
PlugNMeetError: If room creation fails
"""
subject = session.subject or f"{course.title} Live Session"
room_id = session.room_id
metadata = self._build_metadata(subject)
payload = {
'room_id': room_id,
'metadata': metadata,
}
logger.info(f"[Room Recreate] Recreating room in PlugNMeet - room_id={room_id} session_id={session.id}")
try:
client = PlugNMeetClient()
plugnmeet_response = client.create_room(payload)
logger.info(f"[Room Recreate] Room recreated successfully - room_id={room_id} response={plugnmeet_response}")
# Reset session ended_at to mark it as active again
session.ended_at = None
session.save(update_fields=['ended_at', 'updated_at'])
logger.info(f"[Room Recreate] Session reactivated - session_id={session.id}")
except PlugNMeetError as exc:
logger.error(f"[Room Recreate] Failed to recreate room - room_id={room_id} error={str(exc)}")
raise
@staticmethod
def _build_profile_url(request, user):
avatar = getattr(user, 'avatar', None)
if avatar and getattr(avatar, 'url', None):
return request.build_absolute_uri(avatar.url)
return None
class CourseLiveSessionRecordedFileAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
serializer_class = LiveSessionRecordingSerializer
@swagger_auto_schema(
operation_description="Update recorded file for live session",
tags=["Imam-Javad - Course"],
manual_parameters=[
openapi.Parameter(
'course_id', openapi.IN_PATH,
description="Course ID",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={
200: openapi.Response(
description="Recorded file updated successfully"
)
}
)
def patch(self, request, course_id, *args, **kwargs):
logger.info(f"[LiveSession Recorded File] Request from user_id={request.user.id} for course_id={course_id}")
course = get_object_or_404(Course, id=course_id)
if not request.user.can_manage_course(course):
logger.warning(f"[LiveSession Recorded File] Permission denied - user_id={request.user.id} course_id={course_id}")
raise AppAPIException({'message': 'You do not have permission to update this course.'}, status_code=status.HTTP_403_FORBIDDEN)
try:
session = course.live_sessions.latest('-started_at')
except CourseLiveSession.DoesNotExist:
logger.warning(f"[LiveSession Recorded File] No active session found - course_id={course_id} user_id={request.user.id}")
raise AppAPIException({'message': 'No live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND)
logger.info(f"[LiveSession Recorded File] Latest session found - session_id={session.id} course_id={course_id}")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
recording = LiveSessionRecording.objects.create(
session=session,
file=serializer.validated_data['file'],
title=serializer.validated_data.get('title') or f"{session.subject} Recording",
recording_type=serializer.validated_data.get('recording_type', 'video'),
file_time=serializer.validated_data.get('file_time'),
)
logger.info(f"[LiveSession Recorded File] Recording created successfully - recording_id={recording.id} session_id={session.id} user_id={request.user.id}")
return Response({
'id': recording.id,
'session_id': session.id,
'title': recording.title,
'file': request.build_absolute_uri(recording.file.url),
'recording_type': recording.recording_type,
'file_time': recording.file_time,
'is_active': recording.is_active,
}, status=status.HTTP_201_CREATED)

77
apps/course/views/participant.py

@ -1,77 +0,0 @@
from rest_framework import generics
from rest_framework.exceptions import NotFound
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.permissions import IsAuthenticated
from apps.account.models import StudentUser
from apps.course.models import Participant, Course
from apps.course.serializers import ParticipantSerializer
from apps.account.serializers import UserProfileSerializer
from apps.course.doc import *
from utils.exceptions import AppAPIException
from utils.pagination import StandardResultsSetPagination
class CourseParticipantsView(generics.ListAPIView):
serializer_class = UserProfileSerializer
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description=doc_course_participants(),
tags=['Imam-Javad - Course'],
)
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related for course relationship.
Filters out guest users (no email) and soft-deleted users (is_active=False).
"""
course_slug = self.kwargs.get('slug')
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
raise AppAPIException({'message': "Course not found"})
# 👇 Apply the strict filters for Normal Users only
return StudentUser.objects.select_related().filter(
participated_courses__course=course,
is_active=True, # Exclude soft-deleted users
email__isnull=False # Exclude guest users
).exclude(
email__exact='' # Extra safety just in case an email is a blank string
)
# class ParticipantCreateView(generics.CreateAPIView):
# queryset = StudentUser.objects.all()
# serializer_class = ParticipantSerializer
# permission_classes = [IsAuthenticated]
# def create(self, request, *args, **kwargs):
# user = request.user
# course_slug = self.kwargs.get('slug') # Get the slug from the URL
# try:
# course = Course.objects.get(slug=slug) # Retrieve the Course object
# except Course.DoesNotExist:
# raise AppAPIException({'message': "Course not found"}) # Handle course not found
# if request.data.get('email') != request.user:
# raise AppAPIException({'message': "The email must be for the requesting user"})
# if user.user_type != User.UserType.STUDENT:
# user.change_user_type(User.UserType.STUDENT)
# participant, created = Participant.objects.get_or_create(
# student=user,
# course=course
# )
# serializer = self.get_serializer(participant)
# return Response(serializer.data, status=status.HTTP_201_CREATED)

205
apps/course/views/professor.py

@ -1,205 +0,0 @@
from django.contrib.auth import get_user_model
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
from rest_framework.filters import SearchFilter
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from apps.course.models import Course
from apps.course.serializers import (
CourseListSerializer,
ProfessorDetailSerializer,
ProfessorListSerializer,
)
from utils.pagination import StandardResultsSetPagination
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
from django.core.cache import cache
from rest_framework.response import Response
UserModel = get_user_model()
class ProfessorListAPIView(ListAPIView):
permission_classes = [AllowAny]
serializer_class = ProfessorListSerializer
filter_backends = [SearchFilter]
search_fields = ['fullname', 'email']
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description='دریافت فهرست استادها به همراه تعداد دوره‌ها و درس‌های فعال هر استاد.',
tags=['Imam-Javad - Course'],
responses={
200: openapi.Response(
description='فهرست صفحه‌بندی‌شده‌ی استادها.',
schema=ProfessorListSerializer(many=True),
examples={
'application/json': {
'count': 1,
'next': None,
'previous': None,
'results': [
{
'id': 7,
'slug': 'dr-rahimi',
'fullname': 'دکتر رحیمی',
'experience_years': 10,
'course_count': 4,
'lesson_count': 56,
}
],
}
},
)
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return (
UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR)
.annotate(
course_count=Count('courses', distinct=True),
lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True),
)
.order_by('fullname')
)
class ProfessorDetailAPIView(RetrieveAPIView):
permission_classes = [AllowAny]
serializer_class = ProfessorDetailSerializer
lookup_field = 'slug'
@swagger_auto_schema(
operation_description='دریافت جزئیات یک استاد بر اساس اسلاگ.',
tags=['Imam-Javad - Course'],
responses={
200: openapi.Response(
description='اطلاعات کامل استاد.',
schema=ProfessorDetailSerializer(),
examples={
'application/json': {
'id': 7,
'device_id': 'abc-123',
'fcm': None,
'fullname': 'دکتر رحیمی',
'avatar': None,
'email': 'rahimi@example.com',
'phone_number': '+989121234567',
'password': None,
'info': 'متخصص فیزیک پزشکی.',
'skill': 'فیزیک، تدریس آنلاین',
'city': 'تهران',
'country': 'ایران',
'birthdate': '1985-04-12',
'gender': 'male',
'slug': 'dr-rahimi',
'experience_years': 10,
'course_count': 4,
'lesson_count': 56,
}
},
)
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
slug = self.kwargs.get('slug')
cache_key = f"professor_detail_{slug}"
# Try to get the data from Redis/Cache
cached_data = cache.get(cache_key)
if cached_data:
return Response(cached_data)
# If not in cache, let DRF do the heavy lifting (DB queries, serialization)
response = super().retrieve(request, *args, **kwargs)
# Store the serialized data in the cache for 24 hours
cache.set(cache_key, response.data, timeout=60 * 60 * 24)
return response
def get_queryset(self):
return UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR).annotate(
course_count=Count('courses', distinct=True),
lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True),
)
class ProfessorCourseListAPIView(ListAPIView):
permission_classes = [AllowAny]
serializer_class = CourseListSerializer
filter_backends = [SearchFilter]
search_fields = ['title', 'category__name']
pagination_class = StandardResultsSetPagination
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
@swagger_auto_schema(
operation_description='دریافت فهرست دوره‌های فعال یک استاد مشخص‌شده با اسلاگ.',
tags=['Imam-Javad - Course'],
responses={
200: openapi.Response(
description='فهرست صفحه‌بندی‌شده‌ی دوره‌ها.',
schema=CourseListSerializer(many=True),
examples={
'application/json': {
'count': 1,
'next': None,
'previous': None,
'results': [
{
'id': 42,
'title': 'فیزیک پایه',
'slug': 'basic-physics',
'participant_count': 150,
'category': {
'name': 'علوم پایه',
'slug': 'basic-science',
'course_count': 12,
},
'thumbnail': None,
'is_online': True,
'online_link': 'https://example.com/live/basic-physics',
'level': 'beginner',
'duration': '12h',
'lessons_count': 24,
'short_description': 'مروری بر مفاهیم پایه فیزیک.',
'status': 'published',
'is_free': False,
'price': '250000',
'discount_percentage': 20,
'final_price': '200000',
}
],
}
},
)
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
slug = self.kwargs.get('slug')
professor = get_object_or_404(UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR, slug=slug))
return Course.objects.select_related('category', 'professor').prefetch_related(
'lessons__lesson',
'lessons__completions',
'participants__student',
).exclude(status=Course.StatusChoices.INACTIVE).filter(professor=professor)

279
apps/course/views/webhook.py

@ -1,279 +0,0 @@
import json
import hmac
import hashlib
import logging
import os
import tempfile
import subprocess
from typing import Dict, Any
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.core.files.base import ContentFile
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.parsers import BaseParser
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant, LiveSessionRecording
from apps.account.models import User
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
from utils.exceptions import AppAPIException
logger = logging.getLogger(__name__)
class RawJSONParser(BaseParser):
"""
Parser that preserves the raw body bytes for HMAC signature verification.
"""
media_type = 'application/json'
def parse(self, stream, media_type=None, parser_context=None):
return stream.read()
@method_decorator(csrf_exempt, name='dispatch')
class PlugNMeetWebhookAPIView(APIView):
"""
Webhook endpoint to receive and process events from the PlugNMeet server.
Handles:
- room_finished: Closes the live session record.
- participant_joined: Tracks student entry.
- participant_left: Tracks student exit.
- end_recording: Downloads and saves session recordings.
"""
authentication_classes = []
permission_classes = [AllowAny]
parser_classes = [RawJSONParser]
@swagger_auto_schema(
operation_description="Handle webhook events from PlugNMeet server for live sessions",
tags=["Imam-Javad - Course"],
responses={
200: openapi.Response(description="Webhook processed successfully"),
403: openapi.Response(description="Invalid signature"),
400: openapi.Response(description="Invalid payload")
}
)
def post(self, request, *args, **kwargs):
logger.info("⚡ [PlugNMeet Webhook] Request received")
# 1. Extract Signature
hash_token = request.headers.get('Hash-Token') or request.META.get('HTTP_HASH_TOKEN')
if not hash_token:
logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token header")
return Response({'message': 'Missing Hash-Token header'}, status=403)
# 2. Verify Signature
if not self._verify_webhook_signature(request, hash_token):
return Response({'message': 'Invalid webhook signature'}, status=403)
# 3. Parse Payload
try:
body_bytes = request.data # RawJSONParser puts bytes here
payload = json.loads(body_bytes.decode('utf-8'))
except Exception as e:
logger.error(f"❌ [PlugNMeet Webhook] Parsing Error: {e}")
return Response({'message': 'Invalid JSON'}, status=400)
event = payload.get('event')
logger.info(f"✅ [PlugNMeet Webhook] Event: {event}")
# 4. Route Event
handler_map = {
'room_finished': self._handle_room_finished,
'participant_joined': self._handle_participant_joined,
'participant_left': self._handle_participant_left,
'end_recording': self._handle_end_recording,
}
handler = handler_map.get(event)
if not handler:
logger.info(f"ℹ️ [PlugNMeet Webhook] Event {event} ignored")
return Response({'status': 'ok', 'message': f'Event {event} ignored'})
try:
result = handler(payload)
return Response({'status': 'ok', **result})
except Exception as e:
logger.error(f"❌ [PlugNMeet Webhook] Error in {event}: {e}", exc_info=True)
return Response({'status': 'error', 'message': str(e)}, status=500)
def _verify_webhook_signature(self, request, hash_token: str) -> bool:
api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None)
if not api_secret:
logger.error("❌ [PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured")
return False
body_bytes = request.data
expected_signature = hmac.new(
api_secret.encode('utf-8'),
body_bytes,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(hash_token, expected_signature):
logger.error(f"❌ [PlugNMeet Webhook] Signature mismatch! \nReceived: {hash_token[:10]}...\nExpected: {expected_signature[:10]}...")
return False
return True
def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]:
room_data = payload.get('room', {})
room_id = room_data.get('identity')
if not room_id:
return {'message': 'Missing room identity'}
try:
session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True)
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
# Close active user sessions
updated_count = LiveSessionUser.objects.filter(
session=session,
is_online=True,
exited_at__isnull=True
).update(is_online=False, exited_at=now, updated_at=now)
logger.info(f"🏁 [PlugNMeet Webhook] Session {session.id} ended. Users disconnected: {updated_count}")
return {'session_id': session.id, 'closed_users': updated_count}
except CourseLiveSession.DoesNotExist:
return {'message': 'No active session found for this room'}
def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]:
room_data = payload.get('room', {})
participant_data = payload.get('participant', {})
room_id = room_data.get('identity')
user_id = participant_data.get('identity')
if not room_id or not user_id:
return {'message': 'Missing required metadata'}
try:
session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True)
user = User.objects.get(id=int(user_id))
role = 'moderator' if user.can_manage_course(session.course) else 'participant'
session_user, created = LiveSessionUser.objects.update_or_create(
session=session,
user=user,
defaults={
'role': role,
'is_online': True,
'exited_at': None,
'entered_at': timezone.now()
}
)
logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id}")
return {'session_user_id': session_user.id, 'created': created}
except Exception as e:
return {'error': str(e)}
def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]:
room_data = payload.get('room', {})
participant_data = payload.get('participant', {})
room_id = room_data.get('identity')
user_id = participant_data.get('identity')
try:
session = CourseLiveSession.objects.get(room_id=room_id)
user = User.objects.get(id=int(user_id))
updated = LiveSessionUser.objects.filter(
session=session, user=user, is_online=True
).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now())
logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id}")
return {'updated': bool(updated)}
except Exception as e:
return {'error': str(e)}
def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]:
room_data = payload.get('room', {})
recording_info = payload.get('recording_info', {})
room_id = room_data.get('identity')
recording_id = recording_info.get('recordingId')
file_name = recording_info.get('fileName', 'recording.mp4')
duration = recording_info.get('duration', 0)
if not room_id or not recording_id:
return {'message': 'Missing recording metadata'}
try:
session = CourseLiveSession.objects.get(room_id=room_id)
client = PlugNMeetClient()
# 1. Fetch download token
token_response = client.get_recording_download_token(recording_id)
if not token_response.get('status'):
return {'error': 'Failed to get download token'}
download_token = token_response.get('token')
download_path = f"/download/recording/{download_token}"
# 2. Download to temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file:
tmp_file_path = tmp_file.name
try:
logger.info(f"📥 [PlugNMeet Webhook] Downloading recording {recording_id}...")
client.download_file(download_path, tmp_file_path)
# 3. Save to Database
with open(tmp_file_path, 'rb') as f:
content = f.read()
recording = LiveSessionRecording.objects.create(
session=session,
title=f"{session.subject} - Recording",
file_time=timezone.timedelta(seconds=duration) if duration > 0 else None,
recording_type='video' if file_name.lower().endswith('.mp4') else 'voice'
)
recording.file.save(file_name, ContentFile(content), save=True)
# 4. Generate thumbnail (Optional)
self._generate_video_thumbnail(tmp_file_path, recording)
logger.info(f"💾 [PlugNMeet Webhook] Recording saved successfully: {recording.id}")
return {'recording_id': recording.id, 'file': file_name}
finally:
if os.path.exists(tmp_file_path):
os.unlink(tmp_file_path)
except Exception as e:
logger.error(f"❌ [PlugNMeet Webhook] End Recording Error: {e}", exc_info=True)
return {'error': str(e)}
def _generate_video_thumbnail(self, video_path: str, recording: LiveSessionRecording) -> bool:
try:
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb:
thumbnail_path = tmp_thumb.name
cmd = [
'ffmpeg', '-ss', '1', '-i', video_path, '-frames:v', '1',
'-q:v', '2', '-vf', 'scale=640:-1', '-y', thumbnail_path
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
if result.returncode == 0 and os.path.exists(thumbnail_path) and os.path.getsize(thumbnail_path) > 0:
with open(thumbnail_path, 'rb') as f:
recording.thumbnail.save(f"thumb_{recording.id}.jpg", ContentFile(f.read()), save=True)
os.unlink(thumbnail_path)
return True
return False
except Exception as e:
logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}")
return False

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save