157 changed files with 527 additions and 17424 deletions
-
132BUGFIX_REPORT.md
-
80CLAUDE.md
-
134OPTIMIZATION_PLAN.md
-
607adjustemnts.md
-
258apps/account/admin/user.py
-
55apps/account/management/commands/migrate_user_roles.py
-
133apps/account/tests/test_account_urls.py
-
138apps/account/tests/test_multiple_roles.py
-
31apps/api/tests.py
-
39apps/article/tests.py
-
0apps/blog/__init__.py
-
126apps/blog/admin.py
-
7apps/blog/apps.py
-
30apps/blog/management/commands/fix_empty_blog_fields.py
-
367apps/blog/management/commands/seed_blog_data.py
-
238apps/blog/migrations/0001_initial.py
-
23apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py
-
76apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py
-
0apps/blog/migrations/__init__.py
-
208apps/blog/models.py
-
142apps/blog/serializers.py
-
3apps/blog/tests.py
-
24apps/blog/urls.py
-
183apps/blog/views.py
-
63apps/bookmark/tests.py
-
0apps/certificate/__init__.py
-
45apps/certificate/admin.py
-
6apps/certificate/apps.py
-
69apps/certificate/migrations/0001_initial.py
-
36apps/certificate/migrations/0002_alter_certificate_course_and_more.py
-
0apps/certificate/migrations/__init__.py
-
28apps/certificate/models.py
-
50apps/certificate/serializers.py
-
3apps/certificate/tests.py
-
11apps/certificate/urls.py
-
56apps/certificate/views.py
-
0apps/chat/__init__.py
-
366apps/chat/admin.py
-
6apps/chat/apps.py
-
1apps/chat/management/__init__.py
-
62apps/chat/management/commands/README.md
-
1apps/chat/management/commands/__init__.py
-
79apps/chat/management/commands/clear_chat_data.py
-
221apps/chat/migrations/0001_initial.py
-
18apps/chat/migrations/0002_roommessage_is_locked.py
-
35apps/chat/migrations/0003_alter_chatmessage_options_and_more.py
-
0apps/chat/migrations/__init__.py
-
184apps/chat/models.py
-
3apps/chat/tests.py
-
3apps/chat/views.py
-
0apps/course/__init__.py
-
4apps/course/admin/__init__.py
-
569apps/course/admin/course.py
-
126apps/course/admin/lesson.py
-
177apps/course/admin/live_session.py
-
33apps/course/admin/participant.py
-
181apps/course/admin/professor_base.py
-
9apps/course/apps.py
-
42apps/course/data/category.json
-
430apps/course/doc.py
-
0apps/course/management/__init__.py
-
0apps/course/management/commands/__init__.py
-
134apps/course/management/commands/clear_course_data.py
-
1007apps/course/migrations/0001_initial.py
-
23apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py
-
23apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py
-
58apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py
-
19apps/course/migrations/0005_alter_course_discount_percentage.py
-
31apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py
-
0apps/course/migrations/__init__.py
-
4apps/course/models/__init__.py
-
275apps/course/models/course.py
-
152apps/course/models/lesson.py
-
189apps/course/models/live_session.py
-
34apps/course/models/participant.py
-
5apps/course/serializers/__init__.py
-
436apps/course/serializers/course.py
-
85apps/course/serializers/lesson.py
-
67apps/course/serializers/online.py
-
17apps/course/serializers/participant.py
-
29apps/course/serializers/professor.py
-
3apps/course/services/__init__.py
-
1660apps/course/services/api.md
-
151apps/course/services/plugnmeet.py
-
82apps/course/signals.py
-
29apps/course/templates/course/add_student_form.html
-
3apps/course/tests.py
-
1apps/course/tests/__init__.py
-
182apps/course/tests/test_live_session_api.py
-
216apps/course/tests/test_multiple_roles_api.py
-
113apps/course/tests/test_professor_api.py
-
312apps/course/token-join-guide.md
-
37apps/course/urls.py
-
6apps/course/views/__init__.py
-
822apps/course/views/course.py
-
168apps/course/views/lesson.py
-
661apps/course/views/live_session.py
-
77apps/course/views/participant.py
-
205apps/course/views/professor.py
-
279apps/course/views/webhook.py
@ -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 |
|||
@ -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) |
|||
|
|||
@ -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 |
|||
@ -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 |
|||
] |
|||
} |
|||
} |
|||
] |
|||
@ -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) |
|||
@ -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) |
|||
@ -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) |
|||
@ -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) |
|||
@ -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' |
|||
@ -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.')) |
|||
@ -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.")) |
|||
@ -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"], |
|||
}, |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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 ''}" |
|||
@ -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()) |
|||
|
|||
|
|||
@ -1,3 +0,0 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -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'), |
|||
] |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
@ -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 |
|||
) |
|||
@ -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) |
|||
@ -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) |
|||
@ -1,6 +0,0 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class CertificateConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.certificate' |
|||
@ -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", |
|||
), |
|||
), |
|||
], |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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}" |
|||
|
|||
|
|||
@ -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) |
|||
|
|||
|
|||
|
|||
@ -1,3 +0,0 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -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'), |
|||
] |
|||
@ -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') |
|||
|
|||
@ -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) |
|||
@ -1,6 +0,0 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class ChatConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.chat' |
|||
@ -1 +0,0 @@ |
|||
|
|||
@ -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 +0,0 @@ |
|||
|
|||
@ -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 |
|||
@ -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")}, |
|||
}, |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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}" |
|||
|
|||
@ -1,3 +0,0 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -1,3 +0,0 @@ |
|||
from django.shortcuts import render |
|||
|
|||
# Create your views here. |
|||
@ -1,4 +0,0 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
from .live_session import * |
|||
@ -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) |
|||
@ -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) |
|||
@ -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) |
|||
@ -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 |
|||
|
|||
@ -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) |
|||
@ -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 |
|||
@ -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" |
|||
} |
|||
] |
|||
@ -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 |
|||
} |
|||
] |
|||
``` |
|||
""" |
|||
@ -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
File diff suppressed because it is too large
View File
@ -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'), |
|||
), |
|||
] |
|||
@ -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', |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -1,4 +0,0 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
from .live_session import * |
|||
@ -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']), |
|||
] |
|||
@ -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" |
|||
|
|||
|
|||
@ -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"]), |
|||
] |
|||
@ -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']), |
|||
] |
|||
@ -1,5 +0,0 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
from .online import * |
|||
from .professor import * |
|||
@ -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'] |
|||
@ -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 |
|||
@ -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 |
|||
@ -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'] |
|||
@ -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'])) |
|||
@ -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
File diff suppressed because it is too large
View File
@ -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"] |
|||
@ -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) |
|||
@ -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 %} |
|||
@ -1,3 +0,0 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -1 +0,0 @@ |
|||
|
|||
@ -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']) |
|||
@ -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) |
|||
@ -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) |
|||
@ -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 فعال است |
|||
@ -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'), |
|||
|
|||
] |
|||
@ -1,6 +0,0 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
from .professor import * |
|||
from .live_session import * |
|||
from .webhook import * |
|||
@ -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}") |
|||
@ -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 |
|||
) |
|||
@ -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) |
|||
@ -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) |
|||
@ -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) |
|||
@ -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
Write
Preview
Loading…
Cancel
Save
Reference in new issue