From 13d80710ba771e89b5b1cd6c57cc144de8831aa3 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 19 May 2026 15:17:16 +0330 Subject: [PATCH] dovodi splited !. --- BUGFIX_REPORT.md | 132 -- CLAUDE.md | 80 - OPTIMIZATION_PLAN.md | 134 -- adjustemnts.md | 607 ------ apps/account/admin/user.py | 258 --- .../management/commands/migrate_user_roles.py | 55 - apps/account/tests/test_account_urls.py | 133 ++ apps/account/tests/test_multiple_roles.py | 138 -- apps/api/tests.py | 31 +- apps/article/tests.py | 39 +- apps/blog/__init__.py | 0 apps/blog/admin.py | 126 -- apps/blog/apps.py | 7 - .../commands/fix_empty_blog_fields.py | 30 - .../management/commands/seed_blog_data.py | 367 ---- apps/blog/migrations/0001_initial.py | 238 --- ...0002_alter_blog_slogan_alter_blog_title.py | 23 - ...r_blog_slug_alter_blog_summary_and_more.py | 76 - apps/blog/migrations/__init__.py | 0 apps/blog/models.py | 208 --- apps/blog/serializers.py | 142 -- apps/blog/tests.py | 3 - apps/blog/urls.py | 24 - apps/blog/views.py | 183 -- apps/bookmark/tests.py | 63 +- apps/certificate/__init__.py | 0 apps/certificate/admin.py | 45 - apps/certificate/apps.py | 6 - apps/certificate/migrations/0001_initial.py | 69 - .../0002_alter_certificate_course_and_more.py | 36 - apps/certificate/migrations/__init__.py | 0 apps/certificate/models.py | 28 - apps/certificate/serializers.py | 50 - apps/certificate/tests.py | 3 - apps/certificate/urls.py | 11 - apps/certificate/views.py | 56 - apps/chat/__init__.py | 0 apps/chat/admin.py | 366 ---- apps/chat/apps.py | 6 - apps/chat/management/__init__.py | 1 - apps/chat/management/commands/README.md | 62 - apps/chat/management/commands/__init__.py | 1 - .../management/commands/clear_chat_data.py | 79 - apps/chat/migrations/0001_initial.py | 221 --- .../migrations/0002_roommessage_is_locked.py | 18 - ...0003_alter_chatmessage_options_and_more.py | 35 - apps/chat/migrations/__init__.py | 0 apps/chat/models.py | 184 -- apps/chat/tests.py | 3 - apps/chat/views.py | 3 - apps/course/__init__.py | 0 apps/course/admin/__init__.py | 4 - apps/course/admin/course.py | 569 ------ apps/course/admin/lesson.py | 126 -- apps/course/admin/live_session.py | 177 -- apps/course/admin/participant.py | 33 - apps/course/admin/professor_base.py | 181 -- apps/course/apps.py | 9 - apps/course/data/category.json | 42 - apps/course/doc.py | 430 ----- apps/course/management/__init__.py | 0 apps/course/management/commands/__init__.py | 0 .../management/commands/clear_course_data.py | 134 -- apps/course/migrations/0001_initial.py | 1007 ---------- ...hat_group_lock_course_is_prof_chat_lock.py | 23 - ...ck_course_is_group_chat_locked_and_more.py | 23 - ...alter_lessoncompletion_options_and_more.py | 58 - .../0005_alter_course_discount_percentage.py | 19 - ...fessor_alter_course_video_file_and_more.py | 31 - apps/course/migrations/__init__.py | 0 apps/course/models/__init__.py | 4 - apps/course/models/course.py | 275 --- apps/course/models/lesson.py | 152 -- apps/course/models/live_session.py | 189 -- apps/course/models/participant.py | 34 - apps/course/serializers/__init__.py | 5 - apps/course/serializers/course.py | 436 ----- apps/course/serializers/lesson.py | 85 - apps/course/serializers/online.py | 67 - apps/course/serializers/participant.py | 17 - apps/course/serializers/professor.py | 29 - apps/course/services/__init__.py | 3 - apps/course/services/api.md | 1660 ----------------- apps/course/services/plugnmeet.py | 151 -- apps/course/signals.py | 82 - .../templates/course/add_student_form.html | 29 - apps/course/tests.py | 3 - apps/course/tests/__init__.py | 1 - apps/course/tests/test_live_session_api.py | 182 -- apps/course/tests/test_multiple_roles_api.py | 216 --- apps/course/tests/test_professor_api.py | 113 -- apps/course/token-join-guide.md | 312 ---- apps/course/urls.py | 37 - apps/course/views/__init__.py | 6 - apps/course/views/course.py | 822 -------- apps/course/views/lesson.py | 168 -- apps/course/views/live_session.py | 661 ------- apps/course/views/participant.py | 77 - apps/course/views/professor.py | 205 -- apps/course/views/webhook.py | 279 --- apps/dobodbi_calendar/tests.py | 23 +- apps/dobodbi_calendar/views.py | 5 +- apps/library/tests.py | 53 +- apps/podcast/tests.py | 51 +- apps/quiz/__init__.py | 0 apps/quiz/admin/__init__.py | 5 - apps/quiz/admin/participant.py | 111 -- apps/quiz/admin/question.py | 85 - apps/quiz/admin/quiz.py | 122 -- apps/quiz/admin/user_rank_quiz.py | 132 -- apps/quiz/apps.py | 6 - apps/quiz/doc.py | 131 -- apps/quiz/management/__init__.py | 0 apps/quiz/management/commands/__init__.py | 0 .../management/commands/clear_quiz_data.py | 78 - apps/quiz/migrations/0001_initial.py | 220 --- ...articipantanswer_answer_timing_and_more.py | 147 -- apps/quiz/migrations/0003_quiz_course.py | 20 - apps/quiz/migrations/__init__.py | 0 apps/quiz/models/__init__.py | 2 - apps/quiz/models/participant.py | 68 - apps/quiz/models/quiz.py | 71 - apps/quiz/serializers/__init__.py | 2 - apps/quiz/serializers/participant.py | 45 - apps/quiz/serializers/quiz.py | 100 - apps/quiz/tests.py | 3 - apps/quiz/urls.py | 13 - apps/quiz/views.py | 3 - apps/quiz/views/__init__.py | 2 - apps/quiz/views/participant.py | 25 - apps/quiz/views/quiz.py | 32 - apps/transaction/__init__.py | 0 apps/transaction/admin.py | 165 -- apps/transaction/apps.py | 9 - apps/transaction/doc.py | 711 ------- apps/transaction/management/__init__.py | 1 - .../management/commands/__init__.py | 1 - .../commands/sync_successful_transactions.py | 201 -- apps/transaction/migrations/0001_initial.py | 209 --- ..._alter_participantinfo_options_and_more.py | 40 - apps/transaction/migrations/__init__.py | 0 apps/transaction/models.py | 127 -- apps/transaction/serializers.py | 96 - apps/transaction/signals.py | 67 - apps/transaction/tests.py | 137 -- apps/transaction/urls.py | 16 - apps/transaction/views.py | 412 ---- apps/video/tests.py | 51 +- config/middleware/site_middleware.py | 32 - config/settings/base.py | 173 +- config/urls.py | 43 +- config/urls_dovoodi.py | 26 - config/urls_imamjavad.py | 26 - create_live_room.sh | 86 - docker-compose.prod.yml | 15 +- test.py | 1 - utils/admin.py | 205 +- 157 files changed, 527 insertions(+), 17424 deletions(-) delete mode 100644 BUGFIX_REPORT.md delete mode 100644 CLAUDE.md delete mode 100644 OPTIMIZATION_PLAN.md delete mode 100644 adjustemnts.md create mode 100644 apps/account/tests/test_account_urls.py delete mode 100644 apps/blog/__init__.py delete mode 100644 apps/blog/admin.py delete mode 100644 apps/blog/apps.py delete mode 100644 apps/blog/management/commands/fix_empty_blog_fields.py delete mode 100644 apps/blog/management/commands/seed_blog_data.py delete mode 100644 apps/blog/migrations/0001_initial.py delete mode 100644 apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py delete mode 100644 apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py delete mode 100644 apps/blog/migrations/__init__.py delete mode 100644 apps/blog/models.py delete mode 100644 apps/blog/serializers.py delete mode 100644 apps/blog/tests.py delete mode 100644 apps/blog/urls.py delete mode 100644 apps/blog/views.py delete mode 100644 apps/certificate/__init__.py delete mode 100644 apps/certificate/admin.py delete mode 100644 apps/certificate/apps.py delete mode 100644 apps/certificate/migrations/0001_initial.py delete mode 100644 apps/certificate/migrations/0002_alter_certificate_course_and_more.py delete mode 100644 apps/certificate/migrations/__init__.py delete mode 100644 apps/certificate/models.py delete mode 100644 apps/certificate/serializers.py delete mode 100644 apps/certificate/tests.py delete mode 100644 apps/certificate/urls.py delete mode 100644 apps/certificate/views.py delete mode 100644 apps/chat/__init__.py delete mode 100644 apps/chat/admin.py delete mode 100644 apps/chat/apps.py delete mode 100644 apps/chat/management/__init__.py delete mode 100644 apps/chat/management/commands/README.md delete mode 100644 apps/chat/management/commands/__init__.py delete mode 100644 apps/chat/management/commands/clear_chat_data.py delete mode 100644 apps/chat/migrations/0001_initial.py delete mode 100644 apps/chat/migrations/0002_roommessage_is_locked.py delete mode 100644 apps/chat/migrations/0003_alter_chatmessage_options_and_more.py delete mode 100644 apps/chat/migrations/__init__.py delete mode 100644 apps/chat/models.py delete mode 100644 apps/chat/tests.py delete mode 100644 apps/chat/views.py delete mode 100644 apps/course/__init__.py delete mode 100644 apps/course/admin/__init__.py delete mode 100644 apps/course/admin/course.py delete mode 100644 apps/course/admin/lesson.py delete mode 100644 apps/course/admin/live_session.py delete mode 100644 apps/course/admin/participant.py delete mode 100644 apps/course/admin/professor_base.py delete mode 100644 apps/course/apps.py delete mode 100644 apps/course/data/category.json delete mode 100644 apps/course/doc.py delete mode 100644 apps/course/management/__init__.py delete mode 100644 apps/course/management/commands/__init__.py delete mode 100644 apps/course/management/commands/clear_course_data.py delete mode 100644 apps/course/migrations/0001_initial.py delete mode 100644 apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py delete mode 100644 apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py delete mode 100644 apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py delete mode 100644 apps/course/migrations/0005_alter_course_discount_percentage.py delete mode 100644 apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py delete mode 100644 apps/course/migrations/__init__.py delete mode 100644 apps/course/models/__init__.py delete mode 100644 apps/course/models/course.py delete mode 100644 apps/course/models/lesson.py delete mode 100644 apps/course/models/live_session.py delete mode 100644 apps/course/models/participant.py delete mode 100644 apps/course/serializers/__init__.py delete mode 100644 apps/course/serializers/course.py delete mode 100644 apps/course/serializers/lesson.py delete mode 100644 apps/course/serializers/online.py delete mode 100644 apps/course/serializers/participant.py delete mode 100644 apps/course/serializers/professor.py delete mode 100644 apps/course/services/__init__.py delete mode 100644 apps/course/services/api.md delete mode 100644 apps/course/services/plugnmeet.py delete mode 100644 apps/course/signals.py delete mode 100644 apps/course/templates/course/add_student_form.html delete mode 100644 apps/course/tests.py delete mode 100644 apps/course/tests/__init__.py delete mode 100644 apps/course/tests/test_live_session_api.py delete mode 100644 apps/course/tests/test_multiple_roles_api.py delete mode 100644 apps/course/tests/test_professor_api.py delete mode 100644 apps/course/token-join-guide.md delete mode 100644 apps/course/urls.py delete mode 100644 apps/course/views/__init__.py delete mode 100644 apps/course/views/course.py delete mode 100644 apps/course/views/lesson.py delete mode 100644 apps/course/views/live_session.py delete mode 100644 apps/course/views/participant.py delete mode 100644 apps/course/views/professor.py delete mode 100644 apps/course/views/webhook.py delete mode 100644 apps/quiz/__init__.py delete mode 100644 apps/quiz/admin/__init__.py delete mode 100644 apps/quiz/admin/participant.py delete mode 100644 apps/quiz/admin/question.py delete mode 100644 apps/quiz/admin/quiz.py delete mode 100644 apps/quiz/admin/user_rank_quiz.py delete mode 100644 apps/quiz/apps.py delete mode 100644 apps/quiz/doc.py delete mode 100644 apps/quiz/management/__init__.py delete mode 100644 apps/quiz/management/commands/__init__.py delete mode 100644 apps/quiz/management/commands/clear_quiz_data.py delete mode 100644 apps/quiz/migrations/0001_initial.py delete mode 100644 apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py delete mode 100644 apps/quiz/migrations/0003_quiz_course.py delete mode 100644 apps/quiz/migrations/__init__.py delete mode 100644 apps/quiz/models/__init__.py delete mode 100644 apps/quiz/models/participant.py delete mode 100644 apps/quiz/models/quiz.py delete mode 100644 apps/quiz/serializers/__init__.py delete mode 100644 apps/quiz/serializers/participant.py delete mode 100644 apps/quiz/serializers/quiz.py delete mode 100644 apps/quiz/tests.py delete mode 100644 apps/quiz/urls.py delete mode 100644 apps/quiz/views.py delete mode 100644 apps/quiz/views/__init__.py delete mode 100644 apps/quiz/views/participant.py delete mode 100644 apps/quiz/views/quiz.py delete mode 100644 apps/transaction/__init__.py delete mode 100644 apps/transaction/admin.py delete mode 100644 apps/transaction/apps.py delete mode 100644 apps/transaction/doc.py delete mode 100644 apps/transaction/management/__init__.py delete mode 100644 apps/transaction/management/commands/__init__.py delete mode 100644 apps/transaction/management/commands/sync_successful_transactions.py delete mode 100644 apps/transaction/migrations/0001_initial.py delete mode 100644 apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py delete mode 100644 apps/transaction/migrations/__init__.py delete mode 100644 apps/transaction/models.py delete mode 100644 apps/transaction/serializers.py delete mode 100644 apps/transaction/signals.py delete mode 100644 apps/transaction/tests.py delete mode 100644 apps/transaction/urls.py delete mode 100644 apps/transaction/views.py delete mode 100644 config/middleware/site_middleware.py delete mode 100644 config/urls_dovoodi.py delete mode 100644 config/urls_imamjavad.py delete mode 100755 create_live_room.sh diff --git a/BUGFIX_REPORT.md b/BUGFIX_REPORT.md deleted file mode 100644 index e08a028..0000000 --- a/BUGFIX_REPORT.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c400361..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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 `. - - [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) - diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md deleted file mode 100644 index ec3e538..0000000 --- a/OPTIMIZATION_PLAN.md +++ /dev/null @@ -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 diff --git a/adjustemnts.md b/adjustemnts.md deleted file mode 100644 index 9717968..0000000 --- a/adjustemnts.md +++ /dev/null @@ -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 - ] - } - } -] \ No newline at end of file diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py index 91c5eba..5c63a95 100644 --- a/apps/account/admin/user.py +++ b/apps/account/admin/user.py @@ -18,7 +18,6 @@ from unfold.contrib.filters.admin import RangeDateTimeFilter # Import Models from apps.account.models import User, ClientUser, StudentUser, ProfessorUser, LocationHistory -from apps.course.models import Participant # Import Admin Sites from utils from utils.admin import project_admin_site, dovoodi_admin_site , is_dovoodi_panel @@ -198,261 +197,6 @@ class GuestUserAdmin(UserAdmin): return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" -class StudentParticipantInline(StackedInline): - """Inline to show courses a student has joined""" - model = Participant - extra = 0 - readonly_fields = ('course', 'joined_date', 'course_status', 'course_professor') - fields = ('course', 'course_status', 'course_professor', 'joined_date', 'is_active') - verbose_name = _('Course Participation') - verbose_name_plural = _('Course Participations') - autocomplete_fields = ['course'] - tab = True - - def get_queryset(self, request): - qs = super().get_queryset(request) - return qs.select_related('course', 'course__professor') - - @admin.display(description=_('Course Status')) - def course_status(self, obj): - if obj.course: - return obj.course.get_status_display() - return '-' - - @admin.display(description=_('Professor')) - def course_professor(self, obj): - if obj.course and obj.course.professor: - return obj.course.professor.fullname or obj.course.professor.email - return '-' - - def has_add_permission(self, request, obj=None): - return True - def has_change_permission(self, request, obj=None): - return True - def has_delete_permission(self, request, obj=None): - return True - - -class StudentUserAdmin(UserAdmin): - form = UserAdminChangeForm - add_form = UserAdminCreationForm - list_display = ('display_header', 'email', 'gender', 'display_age', 'courses_count') - - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': (('fullname', 'email'), 'phone_number', 'avatar', 'birthdate', 'gender'), - }), - (_('Location'), { - 'fields': (('city', 'country'),), - 'classes': ('collapse',), - }), - (_('password'), { - 'fields': ('password1', 'password2',), - 'classes': ('collapse',), - }), - ) - inlines = [StudentParticipantInline, LocationHistoryInline] - - @display(description=_("Student"), header=True) - def display_header(self, instance: StudentUser): - avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png") - return [ - instance.fullname, - None, - None, - { - "path": avatar_path, - "height": 30, - "width": 36, - "borderless": True, - }, - ] - - @display(description=_("Age")) - def display_age(self, instance: StudentUser): - from datetime import date - if not instance.birthdate: - return "-" - today = date.today() - birthdate = instance.birthdate - age = today.year - birthdate.year - ((today.month, today.day) < (birthdate.month, birthdate.day)) - formatted_date = birthdate.strftime("%Y-%m-%d") - return format_html('{}', _("Born on {date}").format(date=formatted_date), age) - - @display(description=_("Courses"), dropdown=True) - def courses_count(self, instance: StudentUser): - total = instance.participated_courses.count() - items = [] - for participant in instance.participated_courses.all(): - course = participant.course - title = format_html( - """ - - """, - course.title, - course.id - ) - items.append({"title": title}) - - if total == 0: - return "-" - - return { - "title": ngettext("{total} course", "{total} courses", total).format(total=total), - "items": items, - "striped": True, - } - - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related( - "participated_courses", - "participated_courses__course", - ) - - -class CourseTableSection(TableSection): - verbose_name = _("Course Categories") - related_name = "courses" - height = 380 - fields = ["title", "status", "edit_link"] - - def edit_link(self, instance): - return format_html( - '' - 'visibility' - '', - instance.id - ) - edit_link.short_description = _("Edit") - -class ProfessorUpgradeForm(forms.ModelForm): - existing_user = forms.ModelChoiceField( - queryset=User.objects.filter(is_active=True, email__isnull=False).exclude(groups__name="Professor Group"), - required=True, - label=_("Select Existing User"), - help_text=_("Choose an existing user to upgrade to Professor."), - widget=UnfoldAdminSelectWidget, - ) - - class Meta: - model = ProfessorUser - fields = ("existing_user", "is_active", "is_staff", "is_superuser", "groups") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if 'groups' in self.fields: - self.fields['groups'].required = False - - def _post_clean(self): - # جلوگیری از اعتبارسنجی مدل خالی برای جلوگیری از ارور فیلدهای اجباری - pass - - def save(self, commit=True): - # کاربر موجود (که هنوز پروفسور نیست) را می‌گیریم - user = self.cleaned_data.get('existing_user') - - # ابتدا user_type را تغییر می‌دهیم تا با Manager پروفسور سازگار شود - user.user_type = User.UserType.PROFESSOR - user.is_active = self.cleaned_data.get('is_active', user.is_active) - user.is_staff = self.cleaned_data.get('is_staff', user.is_staff) - user.is_superuser = self.cleaned_data.get('is_superuser', user.is_superuser) - user.save() # ذخیره با مدل User - - # حالا که user_type آپدیت شد، می‌توانیم آن را به عنوان ProfessorUser واکشی کنیم - prof_user = ProfessorUser.objects.get(pk=user.pk) - - # برای ذخیره‌سازی ManyToMany (مثل groups)، باید instance فرم ست شود - self.instance = prof_user - - def save_m2m(): - groups = self.cleaned_data.get('groups') - if groups is not None: - self.instance.groups.set(groups) - # اضافه‌کردن کاربر به گروه پروفسورها و ساخت اسلاگ (در صورت نیاز) - self.instance.ensure_professor_profile(commit=True) - - self.save_m2m = save_m2m - - if commit: - self.save_m2m() - - return prof_user - - -class ProfessorUserAdmin(UserAdmin): - form = UserAdminChangeForm - add_form = ProfessorUpgradeForm # <--- آپدیت شد به فرم ارتقا - list_display = ('display_header', 'email', 'courses_count') - list_sections = [CourseTableSection] - save_as = True - - # بازنویسی کامل فیلدست‌های صفحه Add (ساخت) - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('existing_user',), - }), - (_('Permissions'), { - 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'), - 'classes': ('wide',), - }), - ) - - @display(description=_("Professor"), header=True) - def display_header(self, instance: ProfessorUser): - avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png") - return [ - instance.fullname, - None, - None, - { - "path": avatar_path, - "height": 30, - "width": 50, - "borderless": True, - "squared": True, - }, - ] - - @display(description=_("Courses"), dropdown=True) - def courses_count(self, instance: ProfessorUser): - total = instance.courses.count() - items = [] - for course in instance.courses.all(): - title = format_html( - """ - - """, - course.title, - course.id - ) - items.append({"title": title}) - - if total == 0: - return "-" - - return { - "title": ngettext("{total} course", "{total} courses", total).format(total=total), - "items": items, - "striped": True, - } - - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("courses") - class GroupAdmin(BaseGroupAdmin, ModelAdmin): list_display = ('name', 'permissions_count') @@ -490,8 +234,6 @@ except admin.sites.AlreadyRegistered: # B. PROJECT ADMIN SITE (Imam Javad) project_admin_site.register(User, UserAdmin) project_admin_site.register(ClientUser, GuestUserAdmin) -project_admin_site.register(StudentUser, StudentUserAdmin) -project_admin_site.register(ProfessorUser, ProfessorUserAdmin) project_admin_site.register(Group, GroupAdmin) # C. DOVOODI ADMIN SITE diff --git a/apps/account/management/commands/migrate_user_roles.py b/apps/account/management/commands/migrate_user_roles.py index f5c49f7..7825890 100644 --- a/apps/account/management/commands/migrate_user_roles.py +++ b/apps/account/management/commands/migrate_user_roles.py @@ -4,7 +4,6 @@ Management command برای migration داده‌های موجود به سیست from django.core.management.base import BaseCommand from django.contrib.auth.models import Group from apps.account.models import User -from apps.course.models import Course, Participant class Command(BaseCommand): @@ -31,8 +30,6 @@ class Command(BaseCommand): # Migration کاربران بر اساس user_type فعلی self.migrate_user_types(dry_run) - # Migration کاربرانی که هم استاد و هم دانش‌آموز هستند - self.migrate_professor_students(dry_run) self.stdout.write( self.style.SUCCESS('Migration completed successfully!') @@ -83,58 +80,6 @@ class Command(BaseCommand): self.style.ERROR(f'Group {expected_group_name} does not exist') ) - def migrate_professor_students(self, dry_run): - """شناسایی و migration کاربرانی که هم استاد و هم دانش‌آموز هستند""" - # کاربرانی که دوره ساخته‌اند (استاد هستند) - professors = User.objects.filter(courses__isnull=False).distinct() - - # کاربرانی که در دوره شرکت کرده‌اند (دانش‌آموز هستند) - students = User.objects.filter(participated_courses__isnull=False).distinct() - - # کاربرانی که هم استاد و هم دانش‌آموز هستند - professor_students = professors.filter( - id__in=students.values_list('id', flat=True) - ) - - self.stdout.write( - f'Found {professor_students.count()} users who are both professors and students' - ) - - for user in professor_students: - # اطمینان از اینکه در هر دو گروه هستند - professor_group_exists = user.groups.filter(name="Professor Group").exists() - student_group_exists = user.groups.filter(name="Student Group").exists() - - if not professor_group_exists: - if dry_run: - self.stdout.write( - f'Would add professor role to user {user.email}' - ) - else: - user.add_role('professor') - self.stdout.write( - f'Added professor role to user {user.email}' - ) - - if not student_group_exists: - if dry_run: - self.stdout.write( - f'Would add student role to user {user.email}' - ) - else: - user.add_role('student') - self.stdout.write( - f'Added student role to user {user.email}' - ) - - # نمایش آمار - courses_taught = Course.objects.filter(professor=user).count() - courses_enrolled = Participant.objects.filter(student=user).count() - - self.stdout.write( - f' User {user.email}: teaches {courses_taught} courses, ' - f'enrolled in {courses_enrolled} courses' - ) def get_user_statistics(self): """نمایش آمار کاربران""" diff --git a/apps/account/tests/test_account_urls.py b/apps/account/tests/test_account_urls.py new file mode 100644 index 0000000..1f90cbd --- /dev/null +++ b/apps/account/tests/test_account_urls.py @@ -0,0 +1,133 @@ +from django.urls import reverse +from rest_framework.test import APITestCase + + +class AccountURLResolutionTests(APITestCase): + """ + Test suite to ensure all account, registration, login, profile, and notification API endpoints + resolve and execute cleanly. + """ + + def setUp(self): + from dj_language.models import Language + Language.objects.get_or_create(id=69, defaults={'code': 'fa', 'name': 'Persian', 'status': True, 'countries': []}) + + def test_location_info_endpoint(self): + """Test location-info endpoint is accessible""" + url = reverse('location-info') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_reverse_location_info_endpoint(self): + """Test reverse-location-info endpoint is accessible""" + url = reverse('reverse-location-info') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_region_info_endpoint(self): + """Test region-info endpoint is accessible""" + url = reverse('region-info') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_user_register_endpoint(self): + """Test register endpoint is accessible (POST)""" + url = reverse('user-register') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_web_user_register_endpoint(self): + """Test web register endpoint is accessible (POST)""" + url = reverse('web-user-register') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_verify_endpoint(self): + """Test verify endpoint is accessible (POST)""" + url = reverse('user-verify') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_login_endpoint(self): + """Test login endpoint is accessible (POST)""" + url = reverse('user-login') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_guest_endpoint(self): + """Test guest endpoint is accessible (POST)""" + url = reverse('user-guest') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_exchange_token_endpoint(self): + """Test exchange token endpoint is accessible (POST)""" + url = reverse('exchange-token') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_location_history_endpoint(self): + """Test location update endpoint is accessible (POST)""" + url = reverse('user-location-history') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_location_info_by_coordinates_endpoint(self): + """Test location-info by coordinates endpoint is accessible""" + url = reverse('location-info-by-coordinates') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_user_profile_endpoint(self): + """Test user profile endpoint is accessible""" + url = reverse('user-profile') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_user_recover_endpoint(self): + """Test recover password endpoint is accessible (POST)""" + url = reverse('user-recover') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_reset_endpoint(self): + """Test reset password endpoint is accessible (POST)""" + url = reverse('user-reset') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_notif_endpoint(self): + """Test user notification list endpoint is accessible""" + url = reverse('user-notif') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_user_notif_read_all_endpoint(self): + """Test notifications read-all endpoint is accessible (POST)""" + url = reverse('user-notif-read-all') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_send_notif_endpoint(self): + """Test send notification endpoint is accessible (POST)""" + url = reverse('user-send-notif') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_update_endpoint(self): + """Test user profile update endpoint is accessible (PUT)""" + url = reverse('user-update') + response = self.client.put(url) + self.assertLess(response.status_code, 500) + + def test_user_delete_endpoint(self): + """Test user delete endpoint is accessible (DELETE)""" + url = reverse('user-delete') + response = self.client.delete(url) + self.assertLess(response.status_code, 500) + + def test_update_fcm_endpoint(self): + """Test update FCM endpoint is accessible (POST)""" + url = reverse('update-fcm') + response = self.client.post(url) + self.assertLess(response.status_code, 500) diff --git a/apps/account/tests/test_multiple_roles.py b/apps/account/tests/test_multiple_roles.py index 45993b7..47e3266 100644 --- a/apps/account/tests/test_multiple_roles.py +++ b/apps/account/tests/test_multiple_roles.py @@ -4,8 +4,6 @@ from django.test import TestCase from django.contrib.auth.models import Group from apps.account.models import User -from apps.course.models import Course, CourseCategory, Participant -from apps.transaction.models import TransactionParticipant class MultipleRolesTestCase(TestCase): @@ -26,11 +24,6 @@ class MultipleRolesTestCase(TestCase): self.user.language = None self.user.save() - # ایجاد دسته‌بندی دوره - self.category = CourseCategory.objects.create( - name='Test Category', - slug='test-category' - ) def test_user_can_have_multiple_roles(self): """تست اینکه کاربر می‌تواند چندین نقش داشته باشد""" @@ -66,90 +59,6 @@ class MultipleRolesTestCase(TestCase): # نقش اصلی باید student شود self.assertEqual(self.user.primary_role, User.UserType.STUDENT) - def test_course_creation_and_enrollment(self): - """تست ایجاد دوره و ثبت‌نام در دوره دیگر""" - # کاربر نقش professor می‌گیرد - self.user.add_role('professor') - - # ایجاد دوره - course1 = Course.objects.create( - title='Test Course 1', - slug='test-course-1', - category=self.category, - professor=self.user, - level='beginner', - duration=10, - lessons_count=5, - description='Test description' - ) - - # بررسی اینکه کاربر می‌تواند دوره را مدیریت کند - self.assertTrue(self.user.can_manage_course(course1)) - - # کاربر دیگری دوره دیگری می‌سازد - other_user = User.objects.create_user( - email='other@example.com', - fullname='Other User', - password='testpass123' - ) - other_user.language = None - other_user.save() - other_user.add_role('professor') - - course2 = Course.objects.create( - title='Test Course 2', - slug='test-course-2', - category=self.category, - professor=other_user, - level='beginner', - duration=10, - lessons_count=5, - description='Test description 2' - ) - - # کاربر اول در دوره دوم شرکت می‌کند - self.user.add_role('student') - participant = Participant.objects.create( - student=self.user, - course=course2 - ) - - # بررسی نقش‌ها - self.assertTrue(self.user.has_role('professor')) # هنوز استاد است - self.assertTrue(self.user.has_role('student')) # و دانش‌آموز هم هست - - # بررسی دسترسی‌ها - self.assertTrue(self.user.can_manage_course(course1)) # دوره خودش - self.assertFalse(self.user.can_manage_course(course2)) # دوره دیگری - - def test_transaction_preserves_professor_role(self): - """تست اینکه transaction نقش professor را حفظ می‌کند""" - # کاربر استاد می‌شود - self.user.add_role('professor') - - # ایجاد دوره - course = Course.objects.create( - title='Test Course', - slug='test-course', - category=self.category, - professor=self.user, - level='beginner', - duration=10, - lessons_count=5, - description='Test description', - is_free=True - ) - - # شبیه‌سازی transaction (کاربر در دوره‌ای شرکت می‌کند) - if not self.user.has_role('student'): - self.user.add_role('student') - - # بررسی اینکه هر دو نقش حفظ شده‌اند - self.assertTrue(self.user.has_role('professor')) - self.assertTrue(self.user.has_role('student')) - - # نقش اصلی باید professor باشد - self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR) def test_permissions(self): """تست دسترسی‌ها""" @@ -191,50 +100,3 @@ class MultipleRolesTestCase(TestCase): self.user.refresh_from_db() self.assertEqual(self.user.user_type_based_on_groups, User.UserType.CLIENT) - def test_admin_priority_over_professor(self): - """تست اولویت admin بر professor""" - # کاربر هم admin و هم professor است - self.user.add_role('admin') - self.user.add_role('professor') - self.user.is_staff = True - self.user.save() - - # ایجاد دوره - course = Course.objects.create( - title='Test Course', - slug='test-course', - category=self.category, - professor=self.user, - level='beginner', - duration=10, - lessons_count=5, - description='Test description' - ) - - # admin باید دسترسی کامل داشته باشد - self.assertTrue(self.user.can_manage_course(course)) - self.assertTrue(self.user.can_teach_course()) - - # حتی اگر دوره متعلق به کس دیگری باشد - other_user = User.objects.create_user( - email='other@example.com', - fullname='Other User', - password='testpass123' - ) - other_user.language = None - other_user.save() - other_user.add_role('professor') - - other_course = Course.objects.create( - title='Other Course', - slug='other-course', - category=self.category, - professor=other_user, - level='beginner', - duration=10, - lessons_count=5, - description='Other description' - ) - - # admin باید به دوره دیگران هم دسترسی داشته باشد - self.assertTrue(self.user.can_manage_course(other_course)) diff --git a/apps/api/tests.py b/apps/api/tests.py index 7ce503c..b29954a 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -1,3 +1,30 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase -# Create your tests here. + +class ApiURLResolutionTests(APITestCase): + """ + Test suite to ensure general api app endpoints resolve and execute cleanly. + """ + + def test_api_home_endpoint(self): + """Test home endpoint is accessible via direct path""" + response = self.client.get('/api/test/') + self.assertLess(response.status_code, 500) + + def test_api_countries_endpoint(self): + """Test countries endpoint is accessible via direct path""" + response = self.client.get('/api/test/countries/') + self.assertLess(response.status_code, 500) + + def test_comment_list_endpoint(self): + """Test comment list endpoint is accessible""" + url = reverse('comment-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_appversion_list_endpoint(self): + """Test app version list endpoint is accessible""" + url = reverse('appversion-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) diff --git a/apps/article/tests.py b/apps/article/tests.py index 7ce503c..4f2ea8e 100755 --- a/apps/article/tests.py +++ b/apps/article/tests.py @@ -1,3 +1,38 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase -# Create your tests here. + +class ArticleURLResolutionTests(APITestCase): + """ + Test suite to ensure all article API endpoints resolve and execute cleanly. + """ + + def test_category_list_endpoint(self): + """Test article categories endpoint is accessible""" + url = reverse('article:category-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_pinned_collection_list_endpoint(self): + """Test article pinned collections endpoint is accessible""" + url = reverse('article:pinned-collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_collection_list_endpoint(self): + """Test article collections endpoint is accessible""" + url = reverse('article:collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_article_list_endpoint(self): + """Test article list endpoint is accessible""" + url = reverse('article:podcast-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_article_detail_endpoint(self): + """Test article detail endpoint is accessible (may return 404 if no data)""" + url = reverse('article:podcast-detail', kwargs={'slug': 'test-article'}) + response = self.client.get(url) + self.assertLess(response.status_code, 500) diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/blog/admin.py b/apps/blog/admin.py deleted file mode 100644 index 734fcea..0000000 --- a/apps/blog/admin.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/apps/blog/apps.py b/apps/blog/apps.py deleted file mode 100644 index 743ab47..0000000 --- a/apps/blog/apps.py +++ /dev/null @@ -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' diff --git a/apps/blog/management/commands/fix_empty_blog_fields.py b/apps/blog/management/commands/fix_empty_blog_fields.py deleted file mode 100644 index becdca3..0000000 --- a/apps/blog/management/commands/fix_empty_blog_fields.py +++ /dev/null @@ -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.')) \ No newline at end of file diff --git a/apps/blog/management/commands/seed_blog_data.py b/apps/blog/management/commands/seed_blog_data.py deleted file mode 100644 index d958a0a..0000000 --- a/apps/blog/management/commands/seed_blog_data.py +++ /dev/null @@ -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.")) \ No newline at end of file diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py deleted file mode 100644 index d8ba500..0000000 --- a/apps/blog/migrations/0001_initial.py +++ /dev/null @@ -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"], - }, - ), - ] diff --git a/apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py b/apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py deleted file mode 100644 index b129cf1..0000000 --- a/apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py b/apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py deleted file mode 100644 index 52a48a9..0000000 --- a/apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/blog/models.py b/apps/blog/models.py deleted file mode 100644 index bdb4765..0000000 --- a/apps/blog/models.py +++ /dev/null @@ -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 ''}" \ No newline at end of file diff --git a/apps/blog/serializers.py b/apps/blog/serializers.py deleted file mode 100644 index 02ea4ba..0000000 --- a/apps/blog/serializers.py +++ /dev/null @@ -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()) - - diff --git a/apps/blog/tests.py b/apps/blog/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/blog/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/blog/urls.py b/apps/blog/urls.py deleted file mode 100644 index 64de8f3..0000000 --- a/apps/blog/urls.py +++ /dev/null @@ -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//', RelatedBlogsAPIView.as_view(), name='related-blogs'), - - # Blog detail by slug (using regex to support different languages) - re_path(r'^detail/(?P[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$', - BlogDetailBySlugAPIView.as_view(), - name='blog-detail'), -] - - - - - - - diff --git a/apps/blog/views.py b/apps/blog/views.py deleted file mode 100644 index 01b2599..0000000 --- a/apps/blog/views.py +++ /dev/null @@ -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 - ) \ No newline at end of file diff --git a/apps/bookmark/tests.py b/apps/bookmark/tests.py index 7ce503c..ffa4074 100644 --- a/apps/bookmark/tests.py +++ b/apps/bookmark/tests.py @@ -1,3 +1,62 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase -# Create your tests here. + +class BookmarkURLResolutionTests(APITestCase): + """ + Test suite to ensure all bookmark and rate API endpoints resolve and execute cleanly. + """ + + def test_add_bookmark_endpoint(self): + """Test add bookmark endpoint is accessible (POST)""" + url = reverse('bookmark:add_bookmark') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_add_bookmark_list_endpoint(self): + """Test add bookmark list endpoint is accessible (POST)""" + url = reverse('bookmark:add_bookmark_list') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_remove_bookmark_endpoint(self): + """Test remove bookmark endpoint is accessible (POST)""" + url = reverse('bookmark:remove_bookmark') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_remove_bookmark_list_endpoint(self): + """Test remove bookmark list endpoint is accessible (POST)""" + url = reverse('bookmark:remove_bookmark_list') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_bookmark_status_endpoint(self): + """Test bookmark status endpoint is accessible""" + url = reverse('bookmark:bookmark_status') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_add_rate_endpoint(self): + """Test add rate endpoint is accessible (POST)""" + url = reverse('bookmark:add_rate') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_remove_rate_endpoint(self): + """Test remove rate endpoint is accessible (POST)""" + url = reverse('bookmark:remove_rate') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_rate_status_endpoint(self): + """Test rate status endpoint is accessible""" + url = reverse('bookmark:rate_status') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_average_rate_endpoint(self): + """Test average rate endpoint is accessible""" + url = reverse('bookmark:average_rate') + response = self.client.get(url) + self.assertLess(response.status_code, 500) diff --git a/apps/certificate/__init__.py b/apps/certificate/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/certificate/admin.py b/apps/certificate/admin.py deleted file mode 100644 index 92943e1..0000000 --- a/apps/certificate/admin.py +++ /dev/null @@ -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('{}', status_class, obj.get_status_display()) - - def get_queryset(self, request): - queryset = super().get_queryset(request) - return queryset -project_admin_site.register(Certificate, CertificateAdmin) \ No newline at end of file diff --git a/apps/certificate/apps.py b/apps/certificate/apps.py deleted file mode 100644 index 6a8b903..0000000 --- a/apps/certificate/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class CertificateConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.certificate' diff --git a/apps/certificate/migrations/0001_initial.py b/apps/certificate/migrations/0001_initial.py deleted file mode 100644 index 336915a..0000000 --- a/apps/certificate/migrations/0001_initial.py +++ /dev/null @@ -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", - ), - ), - ], - ), - ] diff --git a/apps/certificate/migrations/0002_alter_certificate_course_and_more.py b/apps/certificate/migrations/0002_alter_certificate_course_and_more.py deleted file mode 100644 index ba8c885..0000000 --- a/apps/certificate/migrations/0002_alter_certificate_course_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/certificate/migrations/__init__.py b/apps/certificate/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/certificate/models.py b/apps/certificate/models.py deleted file mode 100644 index 9a321ee..0000000 --- a/apps/certificate/models.py +++ /dev/null @@ -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}" - - \ No newline at end of file diff --git a/apps/certificate/serializers.py b/apps/certificate/serializers.py deleted file mode 100644 index c816135..0000000 --- a/apps/certificate/serializers.py +++ /dev/null @@ -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) - - - diff --git a/apps/certificate/tests.py b/apps/certificate/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/certificate/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/certificate/urls.py b/apps/certificate/urls.py deleted file mode 100644 index 9b80b74..0000000 --- a/apps/certificate/urls.py +++ /dev/null @@ -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'), -] \ No newline at end of file diff --git a/apps/certificate/views.py b/apps/certificate/views.py deleted file mode 100644 index 589c6ef..0000000 --- a/apps/certificate/views.py +++ /dev/null @@ -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') - \ No newline at end of file diff --git a/apps/chat/__init__.py b/apps/chat/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/chat/admin.py b/apps/chat/admin.py deleted file mode 100644 index a279a62..0000000 --- a/apps/chat/admin.py +++ /dev/null @@ -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('{}', _("Read")) - return format_html('{}', _("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( - '' - '{}', count - ) - - @display(description=_("Room Type")) - def room_type_badge(self, obj): - if obj.room_type == 'group': - return format_html('{}', _("Group")) - return format_html('{}', _("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( - '{}', - bg_color, label - ) - - @display(description=_("Status")) - def is_deleted_status(self, obj): - if obj.is_deleted: - return format_html('{}', _("Deleted")) - return format_html('{}', _("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('{}', _("📷 Image")) - elif obj.file_attachment: - return format_html('{}', _("📎 File")) - elif obj.content and obj.content_type != 'text': - return format_html('{}', _("🔗 Legacy")) - return "-" - - @display(description=_("Attachment Preview")) - def attachment_preview(self, obj): - if obj.image_attachment: - return format_html( - '
{}:
' - '' - '
{}
', - _("Image"), - obj.image_attachment.url, - obj.image_attachment.url, - _("Open in new tab") - ) - elif obj.file_attachment: - return format_html( - '
{}:
' - '{}
', - _("File"), - obj.file_attachment.url, - _("📥 Download File") - ) - elif obj.content and obj.content_type != 'text': - return format_html( - '
{}:
{}
', - _("Legacy URL"), - obj.content - ) - return "-" - -project_admin_site.register(RoomMessage, RoomMessageAdmin) -project_admin_site.register(ChatMessage, ChatMessageAdmin) -project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin) \ No newline at end of file diff --git a/apps/chat/apps.py b/apps/chat/apps.py deleted file mode 100644 index 2d27770..0000000 --- a/apps/chat/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ChatConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.chat' diff --git a/apps/chat/management/__init__.py b/apps/chat/management/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/apps/chat/management/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/chat/management/commands/README.md b/apps/chat/management/commands/README.md deleted file mode 100644 index c70c27f..0000000 --- a/apps/chat/management/commands/README.md +++ /dev/null @@ -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! -``` diff --git a/apps/chat/management/commands/__init__.py b/apps/chat/management/commands/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/apps/chat/management/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/chat/management/commands/clear_chat_data.py b/apps/chat/management/commands/clear_chat_data.py deleted file mode 100644 index 2546076..0000000 --- a/apps/chat/management/commands/clear_chat_data.py +++ /dev/null @@ -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 diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py deleted file mode 100644 index cc82c51..0000000 --- a/apps/chat/migrations/0001_initial.py +++ /dev/null @@ -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")}, - }, - ), - ] diff --git a/apps/chat/migrations/0002_roommessage_is_locked.py b/apps/chat/migrations/0002_roommessage_is_locked.py deleted file mode 100644 index ccec89c..0000000 --- a/apps/chat/migrations/0002_roommessage_is_locked.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/chat/migrations/0003_alter_chatmessage_options_and_more.py b/apps/chat/migrations/0003_alter_chatmessage_options_and_more.py deleted file mode 100644 index 7b6bc0e..0000000 --- a/apps/chat/migrations/0003_alter_chatmessage_options_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/chat/migrations/__init__.py b/apps/chat/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/chat/models.py b/apps/chat/models.py deleted file mode 100644 index aeeef61..0000000 --- a/apps/chat/models.py +++ /dev/null @@ -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}" - \ No newline at end of file diff --git a/apps/chat/tests.py b/apps/chat/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/chat/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/chat/views.py b/apps/chat/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/apps/chat/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/apps/course/__init__.py b/apps/course/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py deleted file mode 100644 index bff68d7..0000000 --- a/apps/course/admin/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .course import * -from .lesson import * -from .participant import * -from .live_session import * diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py deleted file mode 100644 index 423a401..0000000 --- a/apps/course/admin/course.py +++ /dev/null @@ -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( - '' - 'visibility' - '', - 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('{}', 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('{}', _("Free")) - - if instance.discount_percentage > 0: - return format_html( - '${}' - '${}', - instance.price, - instance.final_price - ) - return format_html('${}', 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) \ No newline at end of file diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py deleted file mode 100644 index 830d258..0000000 --- a/apps/course/admin/lesson.py +++ /dev/null @@ -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('{} {}', 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('{} {}', 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) \ No newline at end of file diff --git a/apps/course/admin/live_session.py b/apps/course/admin/live_session.py deleted file mode 100644 index 9c571fd..0000000 --- a/apps/course/admin/live_session.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/apps/course/admin/participant.py b/apps/course/admin/participant.py deleted file mode 100644 index 50a0aae..0000000 --- a/apps/course/admin/participant.py +++ /dev/null @@ -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 - \ No newline at end of file diff --git a/apps/course/admin/professor_base.py b/apps/course/admin/professor_base.py deleted file mode 100644 index b0b976a..0000000 --- a/apps/course/admin/professor_base.py +++ /dev/null @@ -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) diff --git a/apps/course/apps.py b/apps/course/apps.py deleted file mode 100644 index b56f637..0000000 --- a/apps/course/apps.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/apps/course/data/category.json b/apps/course/data/category.json deleted file mode 100644 index 092e449..0000000 --- a/apps/course/data/category.json +++ /dev/null @@ -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" - } -] \ No newline at end of file diff --git a/apps/course/doc.py b/apps/course/doc.py deleted file mode 100644 index 55e929d..0000000 --- a/apps/course/doc.py +++ /dev/null @@ -1,430 +0,0 @@ -def doc_course_participants(): - return """ -# 🐈 Scenario -🛠️ لیست شرکت‌کنندگان دوره - ---- - -## 🚀 درخواست API - -### URL: -``` -GET /api/courses//participants/ -``` - - -## 📊 پاسخ‌ها - -| کد وضعیت | توضیحات | -|---------------|-----------------------------------------------------------| -| `200` | موفقیت‌آمیز - لیستی از شرکت‌کنندگان دوره بازگردانده شد. | -| `404` | دوره یافت نشد. | -| `500` | مشکل موقتی در سرور. | - ---- - - -### پاسخ موفق: -```json -[ - { - "id": 1, - "fullname": "Ali Rezaei", - "avatar": "https://example.com/avatars/ali_rezaei.jpg", - "email": "ali@example.com", - "phone_number": "+98 912 345 6789", - "info": "Experienced Python Developer", - "skill": "Python, Django, REST API" - } -] -``` -""" - - -def doc_courses_lesson(): - return """ -# 🐈 Scenario -🛠️ لیست درس‌های دوره - -این API برای دریافت لیست درس‌های یک دوره خاص استفاده می‌شود. این لیست شامل اطلاعاتی مانند عنوان، اولویت، مدت زمان، نوع محتوا، لینک ویدئو، و وضعیت تکمیل هر درس می‌باشد. - -(مقدار is_complated مشخص میکند آیا کاربر این درس را گذرانده است -ممکن است درس دارای کوعیز باشد که باید در زیر آ» مانند طرح نمایش داده شود -) - -دارای ابجکت کوعیز که لیستی از کوعیز های مربوط به یک درس را نمایش میدهد -بایستی مانند طرح در زیر درس قرار داده شود -و دارای مقدار 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 - } -] -``` -""" diff --git a/apps/course/management/__init__.py b/apps/course/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/course/management/commands/__init__.py b/apps/course/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/course/management/commands/clear_course_data.py b/apps/course/management/commands/clear_course_data.py deleted file mode 100644 index 118cb9c..0000000 --- a/apps/course/management/commands/clear_course_data.py +++ /dev/null @@ -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'))) \ No newline at end of file diff --git a/apps/course/migrations/0001_initial.py b/apps/course/migrations/0001_initial.py deleted file mode 100644 index 6195e4e..0000000 --- a/apps/course/migrations/0001_initial.py +++ /dev/null @@ -1,1007 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-22 10:48 - -import apps.course.models.course -import apps.course.models.lesson -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import utils.schema - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("account", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Attachment", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField(max_length=255, verbose_name="Attachment Title"), - ), - ( - "file", - models.FileField( - upload_to=apps.course.models.course.attachment_file_upload_to, - verbose_name="Attachment File", - ), - ), - ( - "file_size", - models.PositiveIntegerField( - blank=True, null=True, verbose_name="File Size (in bytes)" - ), - ), - ( - "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": "Attachment", - "verbose_name_plural": "Attachments", - }, - ), - migrations.CreateModel( - name="Course", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField(max_length=255, verbose_name="Course Title"), - ), - ("slug", models.SlugField(allow_unicode=True, unique=True)), - ( - "thumbnail", - models.ImageField( - upload_to="courses/thumbnails/", verbose_name="Thumbnail" - ), - ), - ( - "video_type", - models.CharField( - choices=[ - ("youtube_link", "Youtube Link"), - ("video_file", "Video File"), - ], - max_length=20, - verbose_name="Preview Video Type (YouTube Link or File Upload)", - ), - ), - ( - "video_file", - models.FileField( - blank=True, - null=True, - upload_to=apps.course.models.course.course_file_upload_to, - ), - ), - ("video_link", models.CharField(blank=True, max_length=500, null=True)), - ( - "is_online", - models.BooleanField(default=False, verbose_name="Is Online Course"), - ), - ( - "online_link", - models.CharField( - blank=True, - max_length=500, - null=True, - verbose_name="Online Class Link", - ), - ), - ( - "level", - models.CharField( - choices=[ - ("beginner", "Beginner"), - ("mid", "Mid Level"), - ("advanced", "Advanced"), - ], - max_length=10, - verbose_name="Course Level", - ), - ), - ( - "duration", - models.PositiveIntegerField(verbose_name="Duration (in hours)"), - ), - ( - "lessons_count", - models.PositiveIntegerField(verbose_name="Number of Lessons"), - ), - ("description", models.TextField(verbose_name="Course Description")), - ( - "short_description", - models.CharField( - blank=True, - max_length=500, - null=True, - verbose_name="Short Description", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("inactive", "Inactive"), - ("upcoming", "Upcoming"), - ("registering", "Registering"), - ("ongoing", "Ongoing"), - ("finished", "Finished"), - ], - default="inactive", - max_length=15, - verbose_name="Course Status", - ), - ), - ("is_free", models.BooleanField(default=True, verbose_name="Is Free")), - ( - "price", - models.DecimalField( - decimal_places=2, - default=0.0, - max_digits=10, - verbose_name="Course Price", - ), - ), - ( - "discount_percentage", - models.PositiveIntegerField( - default=0, verbose_name="Discount Percentage" - ), - ), - ( - "final_price", - models.DecimalField( - blank=True, - decimal_places=2, - default=0.0, - help_text="This field is automatically calculated based on the discount percentage.", - max_digits=10, - verbose_name="Course Final Price", - ), - ), - ( - "timing", - models.JSONField( - blank=True, - default=utils.schema.default_timing, - null=True, - verbose_name="Timing", - ), - ), - ( - "features", - models.JSONField( - blank=True, - default=dict, - null=True, - verbose_name="Course features", - ), - ), - ( - "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": "Course", - "verbose_name_plural": "Courses", - }, - ), - migrations.CreateModel( - name="CourseCategory", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField(max_length=255, verbose_name="Category Name"), - ), - ("slug", models.SlugField(max_length=255, unique=True)), - ], - ), - migrations.CreateModel( - name="CourseLiveSession", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "room_id", - models.CharField( - blank=True, - help_text="Identifier of the PlugNMeet room.", - max_length=255, - null=True, - unique=True, - verbose_name="Room ID", - ), - ), - ( - "subject", - models.CharField( - help_text="Topic of the live session.", - max_length=255, - verbose_name="Subject", - ), - ), - ( - "started_at", - models.DateTimeField( - help_text="Start time of the live session.", - verbose_name="Started At", - ), - ), - ( - "ended_at", - models.DateTimeField( - blank=True, - help_text="End time of the live session.", - null=True, - verbose_name="Ended At", - ), - ), - ( - "recorded_file", - models.FileField( - blank=True, - help_text="Recorded file of the live session.", - null=True, - upload_to="live_session_recordings/", - verbose_name="Recorded File", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created At"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="Updated At"), - ), - ( - "course", - models.ForeignKey( - help_text="Course that this live session belongs to.", - on_delete=django.db.models.deletion.CASCADE, - related_name="live_sessions", - to="course.course", - verbose_name="Course", - ), - ), - ], - options={ - "verbose_name": "Course Live Session", - "verbose_name_plural": "Course Live Sessions", - "ordering": ("-started_at", "-id"), - }, - ), - migrations.CreateModel( - name="Glossary", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField(max_length=555, verbose_name="Glossary Title"), - ), - ("description", models.TextField(verbose_name="Description")), - ( - "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": "Glossary", - "verbose_name_plural": "Glossaries", - }, - ), - migrations.CreateModel( - name="Lesson", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField(max_length=255, verbose_name="Lesson Title"), - ), - ( - "content_type", - models.CharField( - choices=[ - ("youtube_link", "Youtube Link"), - ("video_file", "Video File"), - ], - max_length=50, - verbose_name="Content Type", - ), - ), - ( - "content_file", - models.FileField( - blank=True, - null=True, - upload_to=apps.course.models.lesson.lesson_file_upload_to, - ), - ), - ( - "video_link", - models.CharField( - blank=True, max_length=500, null=True, verbose_name="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"), - ), - ], - options={ - "verbose_name": "Lesson", - "verbose_name_plural": "Lessons", - "indexes": [ - models.Index( - fields=["content_type"], name="course_less_content_e1cf57_idx" - ), - models.Index( - fields=["created_at"], name="course_less_created_4efb58_idx" - ), - ], - }, - ), - migrations.CreateModel( - name="CourseLesson", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="Course Lesson Title", - ), - ), - ( - "priority", - models.IntegerField(blank=True, null=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"), - ), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="lessons", - to="course.course", - verbose_name="Course", - ), - ), - ( - "lesson", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="course_lessons", - to="course.lesson", - verbose_name="Lesson", - ), - ), - ], - options={ - "verbose_name": "Course Lesson", - "verbose_name_plural": "Course Lessons", - }, - ), - migrations.CreateModel( - name="CourseGlossary", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created at"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="Updated At"), - ), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="glossaries", - to="course.course", - verbose_name="Course", - ), - ), - ( - "glossary", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="course_glossaries", - to="course.glossary", - verbose_name="Glossary", - ), - ), - ], - options={ - "verbose_name": "Course Glossary", - "verbose_name_plural": "Course Glossaries", - "ordering": ("-id",), - }, - ), - migrations.CreateModel( - name="CourseAttachment", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created at"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="Updated At"), - ), - ( - "attachment", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="course_attachments", - to="course.attachment", - verbose_name="Attachment", - ), - ), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="attachments", - to="course.course", - verbose_name="Course", - ), - ), - ], - options={ - "verbose_name": "Course Attachment", - "verbose_name_plural": "Course Attachments", - "ordering": ("-id",), - }, - ), - migrations.AddField( - model_name="course", - name="category", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="courses", - to="course.coursecategory", - verbose_name="Category", - ), - ), - migrations.AddField( - model_name="course", - name="professor", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="courses", - to="account.professoruser", - ), - ), - migrations.CreateModel( - name="Participant", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("is_active", models.BooleanField(default=True)), - ("joined_date", models.DateTimeField(auto_now_add=True)), - ("unread_messages_count", models.IntegerField(default=0)), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="participants", - to="course.course", - ), - ), - ( - "student", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="participated_courses", - to="account.studentuser", - ), - ), - ], - options={ - "indexes": [ - models.Index( - fields=["student"], name="course_part_student_566b08_idx" - ), - models.Index( - fields=["course"], name="course_part_course__7cbf7c_idx" - ), - models.Index( - fields=["joined_date"], name="course_part_joined__27eaa0_idx" - ), - models.Index( - fields=["student", "course"], - name="course_part_student_c97a97_idx", - ), - ], - "unique_together": {("student", "course")}, - }, - ), - migrations.CreateModel( - name="LiveSessionUser", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "role", - models.CharField( - choices=[ - ("participant", "Participant"), - ("moderator", "Moderator"), - ("observer", "Observer"), - ], - help_text="Role of the user in the session", - max_length=50, - verbose_name="Role", - ), - ), - ( - "entered_at", - models.DateTimeField( - help_text="Time the user entered the session", - verbose_name="Entered At", - ), - ), - ( - "exited_at", - models.DateTimeField( - blank=True, - default=None, - help_text="Time the user exited the session", - null=True, - verbose_name="Exited At", - ), - ), - ( - "is_online", - models.BooleanField( - default=True, - help_text="Is the user currently online?", - verbose_name="Is online", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created At"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="Updated At"), - ), - ( - "session", - models.ForeignKey( - help_text="Live session that the user joined.", - on_delete=django.db.models.deletion.CASCADE, - related_name="user_sessions", - to="course.courselivesession", - verbose_name="Live Session", - ), - ), - ( - "user", - models.ForeignKey( - help_text="User participating in the live session.", - on_delete=django.db.models.deletion.CASCADE, - related_name="live_session_entries", - to=settings.AUTH_USER_MODEL, - verbose_name="User", - ), - ), - ], - options={ - "verbose_name": "User Session", - "verbose_name_plural": "User Sessions", - "ordering": ("-entered_at", "-id"), - "indexes": [ - models.Index( - fields=["session", "user"], - name="course_live_session_b1eaa5_idx", - ), - models.Index( - fields=["session", "is_online"], - name="course_live_session_5ef9bc_idx", - ), - models.Index( - fields=["user", "is_online"], - name="course_live_user_id_384830_idx", - ), - ], - "unique_together": {("session", "user", "entered_at")}, - }, - ), - migrations.CreateModel( - name="LiveSessionRecording", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField( - help_text="Title of the recording", - max_length=255, - verbose_name="Title", - ), - ), - ( - "file", - models.FileField( - help_text="File of the recorded session", - upload_to="recorded_sessions/", - verbose_name="Recording File", - ), - ), - ( - "file_time", - models.DurationField( - blank=True, - help_text="Duration of the recording file", - null=True, - verbose_name="File Duration", - ), - ), - ( - "recording_type", - models.CharField( - choices=[("voice", "Voice"), ("video", "Video")], - help_text="Type of the recording (voice or video)", - max_length=10, - verbose_name="Recording Type", - ), - ), - ( - "thumbnail", - models.ImageField( - blank=True, - help_text="Thumbnail image for video recordings", - null=True, - upload_to="recording_thumbnails/", - verbose_name="Thumbnail", - ), - ), - ( - "created_at", - models.DateTimeField( - auto_now_add=True, - help_text="Time the recording was created", - verbose_name="Created At", - ), - ), - ( - "updated_at", - models.DateTimeField( - auto_now=True, - help_text="The datetime when the recording was last updated", - verbose_name="Updated At", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Whether this recording is active or not", - verbose_name="Is Active", - ), - ), - ( - "session", - models.ForeignKey( - help_text="Live session that this recording belongs to.", - on_delete=django.db.models.deletion.CASCADE, - related_name="recordings", - to="course.courselivesession", - verbose_name="Live Session", - ), - ), - ], - options={ - "verbose_name": "Live Session Recording", - "verbose_name_plural": "Live Session Recordings", - "ordering": ("-created_at", "-id"), - "indexes": [ - models.Index( - fields=["session", "is_active"], - name="course_live_session_f35db0_idx", - ), - models.Index( - fields=["session", "recording_type"], - name="course_live_session_84b2bf_idx", - ), - ], - }, - ), - migrations.CreateModel( - name="LessonCompletion", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("completed_at", models.DateTimeField(auto_now_add=True)), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created at"), - ), - ( - "course_lesson", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="completions", - to="course.courselesson", - ), - ), - ( - "student", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="lesson_completions", - to="account.studentuser", - ), - ), - ], - options={ - "indexes": [ - models.Index( - fields=["student"], name="course_less_student_f3c9b8_idx" - ), - models.Index( - fields=["course_lesson"], name="course_less_course__1f3841_idx" - ), - models.Index( - fields=["completed_at"], name="course_less_complet_8d2220_idx" - ), - models.Index( - fields=["student", "course_lesson"], - name="course_less_student_3b6367_idx", - ), - ], - "unique_together": {("student", "course_lesson")}, - }, - ), - migrations.AddIndex( - model_name="courselivesession", - index=models.Index( - fields=["course", "started_at"], name="course_cour_course__b8968b_idx" - ), - ), - migrations.AddIndex( - model_name="courselivesession", - index=models.Index( - fields=["course", "created_at"], name="course_cour_course__142085_idx" - ), - ), - migrations.AddIndex( - model_name="courselivesession", - index=models.Index( - fields=["room_id"], name="course_cour_room_id_ed0222_idx" - ), - ), - migrations.AddIndex( - model_name="courselesson", - index=models.Index( - fields=["course"], name="course_cour_course__4afa4c_idx" - ), - ), - migrations.AddIndex( - model_name="courselesson", - index=models.Index( - fields=["lesson"], name="course_cour_lesson__e5c835_idx" - ), - ), - migrations.AddIndex( - model_name="courselesson", - index=models.Index( - fields=["priority"], name="course_cour_priorit_dedac7_idx" - ), - ), - migrations.AddIndex( - model_name="courselesson", - index=models.Index( - fields=["is_active"], name="course_cour_is_acti_490c61_idx" - ), - ), - migrations.AddIndex( - model_name="courselesson", - index=models.Index( - fields=["course", "priority"], name="course_cour_course__192d2c_idx" - ), - ), - migrations.AddIndex( - model_name="courselesson", - index=models.Index( - fields=["course", "is_active"], name="course_cour_course__7c6f06_idx" - ), - ), - migrations.AddIndex( - model_name="courseattachment", - index=models.Index( - fields=["course"], name="course_cour_course__106cc8_idx" - ), - ), - migrations.AddIndex( - model_name="courseattachment", - index=models.Index( - fields=["attachment"], name="course_cour_attachm_2da12a_idx" - ), - ), - migrations.AddIndex( - model_name="course", - index=models.Index(fields=["status"], name="course_cour_status_57ffd9_idx"), - ), - migrations.AddIndex( - model_name="course", - index=models.Index( - fields=["is_free"], name="course_cour_is_free_9453a1_idx" - ), - ), - migrations.AddIndex( - model_name="course", - index=models.Index( - fields=["created_at"], name="course_cour_created_49f06e_idx" - ), - ), - migrations.AddIndex( - model_name="course", - index=models.Index(fields=["slug"], name="course_cour_slug_235a66_idx"), - ), - migrations.AddIndex( - model_name="course", - index=models.Index( - fields=["status", "created_at"], name="course_cour_status_bfcd24_idx" - ), - ), - migrations.AddIndex( - model_name="course", - index=models.Index( - fields=["category", "status"], name="course_cour_categor_26bb4d_idx" - ), - ), - migrations.AddIndex( - model_name="course", - index=models.Index( - fields=["professor", "status"], name="course_cour_profess_5eae9a_idx" - ), - ), - ] diff --git a/apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py b/apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py deleted file mode 100644 index 4420cf4..0000000 --- a/apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py b/apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py deleted file mode 100644 index 3d25c2e..0000000 --- a/apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py +++ /dev/null @@ -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', - ), - ] diff --git a/apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py b/apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py deleted file mode 100644 index cf071bc..0000000 --- a/apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/course/migrations/0005_alter_course_discount_percentage.py b/apps/course/migrations/0005_alter_course_discount_percentage.py deleted file mode 100644 index df23292..0000000 --- a/apps/course/migrations/0005_alter_course_discount_percentage.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py b/apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py deleted file mode 100644 index 84fe8a8..0000000 --- a/apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/apps/course/migrations/__init__.py b/apps/course/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py deleted file mode 100644 index bff68d7..0000000 --- a/apps/course/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .course import * -from .lesson import * -from .participant import * -from .live_session import * diff --git a/apps/course/models/course.py b/apps/course/models/course.py deleted file mode 100644 index 227c483..0000000 --- a/apps/course/models/course.py +++ /dev/null @@ -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']), - ] diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py deleted file mode 100644 index d8dc00f..0000000 --- a/apps/course/models/lesson.py +++ /dev/null @@ -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" - - \ No newline at end of file diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py deleted file mode 100644 index c58f0bf..0000000 --- a/apps/course/models/live_session.py +++ /dev/null @@ -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"]), - ] diff --git a/apps/course/models/participant.py b/apps/course/models/participant.py deleted file mode 100644 index 497d300..0000000 --- a/apps/course/models/participant.py +++ /dev/null @@ -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']), - ] diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py deleted file mode 100644 index cb5e526..0000000 --- a/apps/course/serializers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .course import * -from .lesson import * -from .participant import * -from .online import * -from .professor import * diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py deleted file mode 100644 index 3a4d689..0000000 --- a/apps/course/serializers/course.py +++ /dev/null @@ -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'] \ No newline at end of file diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py deleted file mode 100644 index e828e51..0000000 --- a/apps/course/serializers/lesson.py +++ /dev/null @@ -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 diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py deleted file mode 100644 index 3e89843..0000000 --- a/apps/course/serializers/online.py +++ /dev/null @@ -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 diff --git a/apps/course/serializers/participant.py b/apps/course/serializers/participant.py deleted file mode 100644 index 8a00bc2..0000000 --- a/apps/course/serializers/participant.py +++ /dev/null @@ -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'] diff --git a/apps/course/serializers/professor.py b/apps/course/serializers/professor.py deleted file mode 100644 index a82b212..0000000 --- a/apps/course/serializers/professor.py +++ /dev/null @@ -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'])) diff --git a/apps/course/services/__init__.py b/apps/course/services/__init__.py deleted file mode 100644 index 2dc8b89..0000000 --- a/apps/course/services/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .plugnmeet import PlugNMeetClient, PlugNMeetError - -__all__ = ['PlugNMeetClient', 'PlugNMeetError'] diff --git a/apps/course/services/api.md b/apps/course/services/api.md deleted file mode 100644 index 53b2c6b..0000000 --- a/apps/course/services/api.md +++ /dev/null @@ -1,1660 +0,0 @@ -# 📚 Plugnmeet Server API Documentation - -> **مستندات کامل API سرور کنفرانس ویدیویی Plugnmeet** - -این مستند راهنمای کامل API های Plugnmeet Server را شامل می‌شود که بر پایه LiveKit ساخته شده است. - ---- - -## 📖 فهرست مطالب - -### 🚀 شروع سریع -- [بررسی فعال بودن روم](#-quick-check-room-status) -- [قابلیت‌های کلیدی](#-core-features) - -### 🔐 احراز هویت و امنیت -- [نحوه احراز هویت](#-authentication) - - [روش `/auth` (HMAC + JSON)](#1-auth-endpoints-hmac--json) - - [روش `/api` (Bearer Token + Protobuf)](#2-api-endpoints-bearer-token--protobuf) - - [روش‌های خاص (LTI & BBB)](#3-special-authentication-methods) - -### 🎯 API Reference -- [**Room Management** - مدیریت اتاق‌ها](#-room-management-api) -- [**Recording Management** - مدیریت ضبط‌ها](#-recording-management-api) -- [**Analytics** - آنالیتیکس و گزارش‌گیری](#-analytics-api) -- [**In-Meeting Controls** - کنترل‌های داخل جلسه](#-in-meeting-controls-api) -- [**Advanced Features** - امکانات پیشرفته](#-advanced-features) - -### 🔧 سایر سرویس‌ها -- [Webhook, Health Check, Downloads](#-other-services) -- [BBB & LTI Compatibility](#-compatibility-apis) - ---- - -## 🚀 Quick Check: Room Status - -ساده‌ترین روش برای بررسی فعال بودن یک روم: - -### Endpoint -```http -POST /auth/room/isRoomActive -``` -d -### Request -```json -{ - "roomId": "your-room-id" -} -``` - -### Response -```json -{ - "status": true, - "msg": "room is active", - "isActive": true -} -``` - -### cURL Example -```bash -#!/bin/bash -API_KEY="your-api-key" -SECRET="your-secret-key" -BODY='{"roomId":"algebra-1402"}' - -# محاسبه HMAC-SHA256 -SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') - -# ارسال درخواست -curl -X POST 'https://your-domain.com/auth/room/isRoomActive' \ - -H "API-KEY: $API_KEY" \ - -H "HASH-SIGNATURE: $SIG" \ - -H 'Content-Type: application/json' \ - -d "$BODY" -``` - ---- - -## ⭐ Core Features - -Plugnmeet Server مجموعه کاملی از قابلیت‌های حرفه‌ای برای برگزاری کنفرانس‌های آنلاین را فراهم می‌کند: - -### 🎥 Video Conferencing -- ✅ HD Audio/Video با کیفیت بالا -- ✅ Screen Sharing - اشتراک‌گذاری صفحه -- ✅ Virtual Backgrounds - پس‌زمینه مجازی -- ✅ Adaptive Streaming (Simulcast & Dynacast) - -### 📊 Collaboration Tools -- ✅ Interactive Whiteboard با پشتیبانی از فایل‌های PDF/Office -- ✅ Shared Notepad - یادداشت مشترک -- ✅ Live Polls - نظرسنجی زنده -- ✅ Breakout Rooms - اتاق‌های گروهی - -### 🎬 Recording & Streaming -- ✅ Cloud Recording - ضبط ابری با فرمت MP4 -- ✅ RTMP Streaming - پخش زنده -- ✅ Ingress Support (RTMP/WHIP) - -### 🛡️ Security & Control -- ✅ Waiting Room - اتاق انتظار -- ✅ Lock Settings - قفل کردن قابلیت‌ها -- ✅ User Management - مدیریت کاربران -- ✅ End-to-End Encryption - -### 📈 Analytics & Monitoring -- ✅ Session Analytics - آنالیتیکس جلسات -- ✅ Participant Reports - گزارش شرکت‌کنندگان -- ✅ Real-time Monitoring - -### ♿ Accessibility -- ✅ Speech-to-Text - گفتار به متن -- ✅ Real-time Translation (Azure) - ---- - -## 🔐 Authentication - -Plugnmeet از سه روش احراز هویت مختلف استفاده می‌کند: - -### 1. `/auth` Endpoints (HMAC + JSON) - -برای عملیات مدیریتی و ساخت توکن‌ها استفاده می‌شود. - -#### Headers -```http -API-KEY: your_api_key -HASH-SIGNATURE: hmac_sha256_hex_signature -Content-Type: application/json -``` - -#### محاسبه HMAC Signature - -**Bash/Shell:** -```bash -SECRET="your-secret-key" -BODY='{"roomId":"test-room"}' -SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') -``` - -**Python:** -```python -import hmac -import hashlib -import json - -secret = "your-secret-key" -body = {"roomId": "test-room"} -body_json = json.dumps(body) - -signature = hmac.new( - secret.encode('utf-8'), - body_json.encode('utf-8'), - hashlib.sha256 -).hexdigest() -``` - -**Node.js:** -```javascript -const crypto = require('crypto'); - -const secret = 'your-secret-key'; -const body = JSON.stringify({ roomId: 'test-room' }); - -const signature = crypto - .createHmac('sha256', secret) - .update(body) - .digest('hex'); -``` - ---- - -### 2. `/api` Endpoints (Bearer Token + Protobuf) - -برای کنترل‌های داخل جلسه استفاده می‌شود. - -#### Headers -```http -Authorization: -Content-Type: application/octet-stream -``` - -> **توکن دسترسی** از طریق `/auth/room/getJoinToken` دریافت می‌شود. - -#### Request/Response Format -- **بدنه درخواست**: Binary Protobuf (استفاده از SDK توصیه می‌شود) -- **پاسخ**: Binary Protobuf - -#### cURL Example با Protobuf -```bash -# ساخت فایل باینری با SDK -# سپس ارسال با curl -curl -X POST 'https://your-domain.com/api/recording' \ - -H "Authorization: $TOKEN" \ - -H 'Content-Type: application/octet-stream' \ - --data-binary @recording_req.bin \ - -o response.bin -``` - -> **نکته**: برخی endpoint های `/api` مانند `convertWhiteboardFile` و `fileUpload` از JSON استفاده می‌کنند. - ---- - -### 3. Special Authentication Methods - -#### LTI (Learning Tools Interoperability) -```http -Authorization: -``` -مسیرها: `/lti/v1/...` - -#### BigBlueButton Compatibility -نیازمند `checksum` محاسبه شده مطابق استاندارد BBB -مسیرها: `/:apiKey/bigbluebutton/api/...` - ---- - -## 📋 Room Management API - -### 🏗️ Create Room - -اتاق جلسه جدید ایجاد می‌کند. - -#### Endpoint -```http -POST /auth/room/create -``` - -#### Request Body -```json -{ - "roomId": "algebra-class-1402", - "maxParticipants": 50, - "emptyTimeout": 300, - "metadata": { - "roomTitle": "کلاس جبر خطی", - "welcomeMessage": "به کلاس جبر خوش آمدید", - "defaultLockSettings": { - "lockMicrophone": false, - "lockWebcam": false, - "lockScreenSharing": true, - "lockChat": false, - "lockChatSendMessage": false, - "lockChatFileShare": false, - "lockPrivateChat": false, - "lockWhiteboard": true, - "lockSharedNotepad": false - }, - "roomFeatures": { - "allowWebcams": true, - "muteOnStart": false, - "allowScreenSharing": true, - "allowRecording": true, - "allowRtmp": true, - "allowViewOtherWebcams": true, - "allowViewOtherParticipantsList": true, - "adminOnlyWebcams": false, - "allowPolls": true, - "roomDuration": 0, - "recordingFeatures": { - "isAllow": true, - "isAllowCloud": true, - "enableAutoCloudRecording": false - }, - "chatFeatures": { - "allowChat": true, - "allowFileUpload": true - }, - "sharedNotePadFeatures": { - "allowedSharedNotePad": true - }, - "whiteboardFeatures": { - "allowedWhiteboard": true, - "preloadFile": "" - }, - "breakoutRoomFeatures": { - "isAllow": true, - "allowedNumberRooms": 6 - }, - "displayExternalLinkFeatures": { - "isAllow": true - }, - "ingressFeatures": { - "isAllow": false - }, - "speechToTextTranslationFeatures": { - "isAllow": true, - "isAllowTranslation": true - } - }, - "webhookUrl": "https://your-domain.com/webhook", - "isBreakoutRoom": false, - "parentRoomId": "" - } -} -``` - -#### Response -```json -{ - "status": true, - "msg": "room created successfully", - "roomId": "algebra-class-1402" -} -``` - ---- - -### 🎫 Generate Join Token - -توکن ورود کاربر به جلسه را ایجاد می‌کند. - -#### Endpoint -```http -POST /auth/room/getJoinToken -``` - -#### Request Body -```json -{ - "roomId": "algebra-class-1402", - "userInfo": { - "userId": "student-123", - "name": "علی احمدی", - "isAdmin": false, - "isHidden": false, - "userMetadata": { - "profilePic": "https://example.com/avatar.jpg", - "lockSettings": { - "lockMicrophone": false, - "lockWebcam": false, - "lockScreenSharing": true, - "lockChat": false - } - } - } -} -``` - -#### Response -```json -{ - "status": true, - "msg": "token generated", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -#### Integration Example -```html - - - - Join Meeting - - - - - -``` - ---- - -### ✅ Check Room Status - -#### Endpoint -```http -POST /auth/room/isRoomActive -``` - -#### Request -```json -{ - "roomId": "algebra-class-1402" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "room is active", - "isActive": true -} -``` - ---- - -### 📊 Get Active Room Info - -اطلاعات کامل یک روم فعال و لیست شرکت‌کنندگان آن را برمی‌گرداند. - -#### Endpoint -```http -POST /auth/room/getActiveRoomInfo -``` - -#### Request -```json -{ - "roomId": "algebra-class-1402" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "success", - "room": { - "roomInfo": { - "sid": "RM_xxxxxxxxxxxx", - "roomId": "algebra-class-1402", - "name": "algebra-class-1402", - "emptyTimeout": 300, - "maxParticipants": 50, - "creationTime": "1699123456", - "metadata": "{...}" - }, - "participantsInfo": [ - { - "sid": "PA_xxxxxxxxxxxx", - "identity": "student-123", - "name": "علی احمدی", - "state": 0, - "joinedAt": "1699123500" - } - ] - } -} -``` - ---- - -### 📋 List All Active Rooms - -لیست تمام روم‌های فعال را برمی‌گرداند. - -#### Endpoint -```http -POST /auth/room/getActiveRoomsInfo -``` - -#### Request -```json -{} -``` - -#### Response -```json -{ - "status": true, - "msg": "success", - "rooms": [ - { - "roomId": "algebra-class-1402", - "sid": "RM_xxxxxxxxxxxx", - "numParticipants": 15, - "creationTime": "1699123456" - }, - { - "roomId": "physics-class-1402", - "sid": "RM_yyyyyyyyyyyy", - "numParticipants": 8, - "creationTime": "1699123789" - } - ] -} -``` - ---- - -### 🛑 End Room - -جلسه را به پایان می‌رساند و تمام شرکت‌کنندگان را خارج می‌کند. - -#### Endpoint -```http -POST /auth/room/endRoom -``` - -#### Request -```json -{ - "roomId": "algebra-class-1402" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "room ended successfully" -} -``` - ---- - -### 📜 Fetch Past Rooms - -لیست روم‌های گذشته را با امکان فیلتر و صفحه‌بندی برمی‌گرداند. - -#### Endpoint -```http -POST /auth/room/fetchPastRooms -``` - -#### Request -```json -{ - "roomIds": ["algebra-class-1402", "physics-class-1402"], - "from": 0, - "limit": 20, - "orderBy": "DESC" -} -``` - -#### Request Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `roomIds` | `string[]` | لیست شناسه‌های روم (اختیاری) | -| `from` | `number` | شروع صفحه‌بندی | -| `limit` | `number` | تعداد نتایج (حداکثر 100) | -| `orderBy` | `string` | ترتیب: `ASC` یا `DESC` | - -#### Response -```json -{ - "status": true, - "msg": "success", - "result": { - "totalRooms": 45, - "from": 0, - "limit": 20, - "orderBy": "DESC", - "roomsList": [ - { - "roomId": "algebra-class-1402", - "sid": "RM_xxxxxxxxxxxx", - "roomTitle": "کلاس جبر خطی", - "creationTime": "1699123456", - "ended": "1699127056", - "roomDuration": 3600 - } - ] - } -} -``` - ---- - -## 🎬 Recording Management API - -### 📋 Fetch Recordings - -لیست ضبط‌های انجام شده را دریافت می‌کند. - -#### Endpoint -```http -POST /auth/recording/fetch -``` - -#### Request -```json -{ - "roomIds": ["algebra-class-1402"], - "from": 0, - "limit": 20, - "orderBy": "DESC" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "success", - "result": { - "totalRecordings": 5, - "from": 0, - "limit": 20, - "orderBy": "DESC", - "recordings": [ - { - "recordId": "rec_xxxxxxxxxxxx", - "roomId": "algebra-class-1402", - "roomSid": "RM_xxxxxxxxxxxx", - "filePath": "/recordings/algebra-class-1402_20231105.mp4", - "fileSize": 524288000, - "creationTime": "1699123456", - "roomCreationTime": "1699120000", - "recordingDuration": 3600 - } - ] - } -} -``` - ---- - -### 📄 Get Recording Info - -اطلاعات کامل یک ضبط را برمی‌گرداند. - -#### Endpoint -```http -POST /auth/recording/recordingInfo -``` - -#### Request -```json -{ - "recordId": "rec_xxxxxxxxxxxx" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "success", - "recordingInfo": { - "recordId": "rec_xxxxxxxxxxxx", - "roomId": "algebra-class-1402", - "filePath": "/recordings/algebra-class-1402_20231105.mp4", - "fileSize": 524288000, - "creationTime": "1699123456" - } -} -``` - ---- - -### 🗑️ Delete Recording - -یک ضبط را حذف می‌کند. - -#### Endpoint -```http -POST /auth/recording/delete -``` - -#### Request -```json -{ - "recordId": "rec_xxxxxxxxxxxx" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "recording deleted successfully" -} -``` - ---- - -### 🔗 Get Download Token - -توکن موقت برای دانلود فایل ضبط شده ایجاد می‌کند. - -#### Endpoint -```http -POST /auth/recording/getDownloadToken -``` - -#### Request -```json -{ - "recordId": "rec_xxxxxxxxxxxx" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "token generated", - "token": "download_token_xxxxxxxxxxxx" -} -``` - -#### Download File -```bash -# دانلود فایل با توکن -curl -o recording.mp4 \ - "https://your-domain.com/download/recording/download_token_xxxxxxxxxxxx" -``` - ---- - -## 📈 Analytics API - -### 📋 Fetch Analytics - -لیست آنالیتیکس جلسات را دریافت می‌کند. - -#### Endpoint -```http -POST /auth/analytics/fetch -``` - -#### Request -```json -{ - "roomIds": ["algebra-class-1402"], - "from": 0, - "limit": 20 -} -``` - -#### Response -```json -{ - "status": true, - "msg": "success", - "result": { - "totalAnalytics": 10, - "analyticsList": [ - { - "analyticsId": "ana_xxxxxxxxxxxx", - "roomId": "algebra-class-1402", - "roomSid": "RM_xxxxxxxxxxxx", - "fileId": "file_xxxxxxxxxxxx", - "fileName": "analytics_algebra-class-1402_20231105.json", - "filePath": "/analytics/algebra-class-1402_20231105.json", - "fileSize": 102400, - "creationTime": "1699127056" - } - ] - } -} -``` - ---- - -### 🗑️ Delete Analytics - -#### Endpoint -```http -POST /auth/analytics/delete -``` - -#### Request -```json -{ - "analyticsId": "ana_xxxxxxxxxxxx" -} -``` - ---- - -### 🔗 Get Download Token - -#### Endpoint -```http -POST /auth/analytics/getDownloadToken -``` - -#### Request -```json -{ - "analyticsId": "ana_xxxxxxxxxxxx" -} -``` - -#### Response -```json -{ - "status": true, - "msg": "token generated", - "token": "analytics_token_xxxxxxxxxxxx" -} -``` - -#### Download Analytics File -```bash -curl -o analytics.json \ - "https://your-domain.com/download/analytics/analytics_token_xxxxxxxxxxxx" -``` - ---- - -## 🎮 In-Meeting Controls API - -> **نکته مهم**: تمام endpoint های این بخش نیازمند **Bearer Token** در هدر `Authorization` هستند و از **Binary Protobuf** استفاده می‌کنند (مگر در موارد خاص که JSON ذکر شده باشد). - -### 🔐 Verify Token - -توکن کاربر را تایید کرده و اطلاعات اتصال را برمی‌گرداند. - -#### Endpoint -```http -POST /api/verifyToken -``` - -#### Request (Protobuf) -```protobuf -message VerifyTokenReq {} -``` - -#### Response (Protobuf) -```protobuf -message VerifyTokenRes { - bool status = 1; - string msg = 2; - string roomId = 3; - string userId = 4; - string roomSid = 5; - repeated string natsWsUrls = 6; - string natsSubject = 7; - string serverVersion = 8; -} -``` - ---- - -### 🎬 Recording & RTMP Control - -#### Start/Stop Recording - -**Endpoint:** -```http -POST /api/recording -``` - -**Request (Protobuf):** -```protobuf -message RecordingReq { - string sid = 1; // Room SID - RecordingTasks task = 2; // START_RECORDING | STOP_RECORDING - string rtmpUrl = 3; // برای RTMP -} - -enum RecordingTasks { - START_RECORDING = 0; - STOP_RECORDING = 1; - START_RTMP = 2; - STOP_RTMP = 3; -} -``` - -**Response (Protobuf):** -```protobuf -message RecordingRes { - bool status = 1; - string msg = 2; -} -``` - ---- - -### 🛑 End Room - -اتاق را از داخل جلسه به پایان می‌رساند (فقط ادمین). - -#### Endpoint -```http -POST /api/endRoom -``` - -#### Request (Protobuf) -```protobuf -message RoomEndReq { - string roomId = 1; -} -``` - ---- - -### 🔒 Update Lock Settings - -تنظیمات قفل کاربران را تغییر می‌دهد (فقط ادمین). - -#### Endpoint -```http -POST /api/updateLockSettings -``` - -#### Request (Protobuf) -```protobuf -message UpdateUserLockSettingsReq { - string roomSid = 1; - string roomId = 2; - string userId = 3; // "all" برای همه | شناسه کاربر خاص - string service = 4; // mic | webcam | screenShare | chat | etc. - string direction = 5; // "lock" | "unlock" -} -``` - -#### Available Services -- `mic` - میکروفون -- `webcam` - وب‌کم -- `screenShare` - اشتراک‌گذاری صفحه -- `chat` - چت -- `sendChatMsg` - ارسال پیام در چت -- `chatFile` - ارسال فایل در چت -- `privateChat` - چت خصوصی -- `whiteboard` - وایت‌برد -- `sharedNotepad` - یادداشت مشترک - ---- - -### 🔇 Mute/Unmute Track - -میکروفون یک یا تمام کاربران را قطع یا وصل می‌کند (فقط ادمین). - -#### Endpoint -```http -POST /api/muteUnmuteTrack -``` - -#### Request (Protobuf) -```protobuf -message MuteUnMuteTrackReq { - string sid = 1; // Room SID - string roomId = 2; - string userId = 3; // "all" برای همه | شناسه کاربر - string trackSid = 4; // اختیاری - bool muted = 5; // true = mute | false = unmute -} -``` - ---- - -### 👤 Remove Participant - -کاربر را از جلسه حذف می‌کند (فقط ادمین). - -#### Endpoint -```http -POST /api/removeParticipant -``` - -#### Request (Protobuf) -```protobuf -message RemoveParticipantReq { - string sid = 1; - string roomId = 2; - string userId = 3; - string msg = 4; // پیام برای کاربر - bool blockUser = 5; // مسدود کردن دائمی -} -``` - ---- - -### 🎤 Switch Presenter - -نقش ارائه‌دهنده را به کاربر می‌دهد یا می‌گیرد (فقط ادمین). - -#### Endpoint -```http -POST /api/switchPresenter -``` - -#### Request (Protobuf) -```protobuf -message SwitchPresenterReq { - string userId = 1; - SwitchPresenterTask task = 2; // PROMOTE | DEMOTE -} - -enum SwitchPresenterTask { - PROMOTE = 0; - DEMOTE = 1; -} -``` - ---- - -## 🎨 Advanced Features - -### 🔗 External Display Link - -لینک خارجی را برای تمام شرکت‌کنندگان نمایش می‌دهد (فقط ادمین). - -#### Endpoint -```http -POST /api/externalDisplayLink -``` - -#### Request (Protobuf) -```protobuf -message ExternalDisplayLinkReq { - ExternalDisplayLinkTask task = 1; // START_EXTERNAL_LINK | STOP_EXTERNAL_LINK - string url = 2; -} -``` - ---- - -### 🎵 External Media Player - -ویدیو یا صدای خارجی را پخش می‌کند (فقط ادمین). - -#### Endpoint -```http -POST /api/externalMediaPlayer -``` - -#### Request (Protobuf) -```protobuf -message ExternalMediaPlayerReq { - ExternalMediaPlayerTask task = 1; // START_PLAYBACK | STOP_PLAYBACK - string url = 2; - bool isPresentation = 3; -} -``` - -> **نکته**: می‌توانید فایل را با `/api/fileUpload` آپلود کرده و لینک `/download/uploadedFile/...` را استفاده کنید. - ---- - -### 🚪 Waiting Room - -#### Approve Users - -کاربران در اتاق انتظار را تایید می‌کند (فقط ادمین). - -**Endpoint:** -```http -POST /api/waitingRoom/approveUsers -``` - -**Request (Protobuf):** -```protobuf -message ApproveWaitingUsersReq { - repeated string userIds = 1; -} -``` - -#### Update Waiting Room Message - -**Endpoint:** -```http -POST /api/waitingRoom/updateMsg -``` - -**Request (Protobuf):** -```protobuf -message UpdateWaitingRoomMessageReq { - string message = 1; -} -``` - ---- - -### 📊 Polls (نظرسنجی) - -#### Create Poll - -نظرسنجی جدید ایجاد می‌کند (فقط ادمین). - -**Endpoint:** -```http -POST /api/polls/create -``` - -**Request (Protobuf):** -```protobuf -message CreatePollReq { - string question = 1; - repeated PollOption options = 2; - bool isAnonymous = 3; - bool allowMultipleVotes = 4; -} - -message PollOption { - uint64 id = 1; - string text = 2; -} -``` - ---- - -#### List Polls - -**Endpoint:** -```http -GET /api/polls/listPolls -``` - -**Response:** Binary Protobuf - ---- - -#### Submit Poll Response - -**Endpoint:** -```http -POST /api/polls/submitResponse -``` - -**Request (Protobuf):** -```protobuf -message SubmitPollResponseReq { - string pollId = 1; - repeated uint64 selectedOptionIds = 2; -} -``` - ---- - -#### Get Poll Results - -**Endpoint:** -```http -GET /api/polls/pollResponsesResult/:pollId -``` - -**Response:** Binary Protobuf با نتایج نظرسنجی - ---- - -### 🏢 Breakout Rooms (اتاق‌های گروهی) - -#### Create Breakout Rooms - -**Endpoint:** -```http -POST /api/breakoutRoom/create -``` - -**Request (Protobuf):** -```protobuf -message CreateBreakoutRoomsReq { - uint64 duration = 1; - repeated BreakoutRoom rooms = 2; -} - -message BreakoutRoom { - string id = 1; - string title = 2; - repeated string userIds = 3; -} -``` - ---- - -#### Join Breakout Room - -**Endpoint:** -```http -POST /api/breakoutRoom/join -``` - -**Request (Protobuf):** -```protobuf -message JoinBreakoutRoomReq { - string breakoutRoomId = 1; -} -``` - ---- - -#### List Breakout Rooms - -**Endpoint:** -```http -GET /api/breakoutRoom/listRooms -``` - ---- - -#### End Breakout Room - -**Endpoint:** -```http -POST /api/breakoutRoom/endRoom -``` - -**Request (Protobuf):** -```protobuf -message EndBreakoutRoomReq { - string breakoutRoomId = 1; -} -``` - ---- - -#### End All Breakout Rooms - -**Endpoint:** -```http -POST /api/breakoutRoom/endAllRooms -``` - ---- - -### 📡 Ingress (RTMP/WHIP Input) - -ورودی استریم خارجی ایجاد می‌کند. - -#### Endpoint -```http -POST /api/ingress/create -``` - -#### Request (Protobuf) -```protobuf -message CreateIngressReq { - IngressInput inputType = 1; // RTMP_INPUT | WHIP_INPUT - string participantName = 2; - string roomId = 3; -} - -enum IngressInput { - RTMP_INPUT = 0; - WHIP_INPUT = 1; -} -``` - -#### Response (Protobuf) -```protobuf -message CreateIngressRes { - bool status = 1; - string msg = 2; - string ingressId = 3; - string url = 4; - string streamKey = 5; -} -``` - -#### Usage Example -پس از دریافت `url` و `streamKey`: -```bash -# استریم با FFmpeg -ffmpeg -re -i input.mp4 \ - -c:v libx264 -c:a aac \ - -f flv "rtmp://url/stream_key" -``` - ---- - -### 🗣️ Speech Services (Azure) - -#### Enable/Disable Speech Service - -**Endpoint:** -```http -POST /api/speechServices/serviceStatus -``` - -**Request (Protobuf):** -```protobuf -message SpeechToTextTranslationReq { - bool enabled = 1; -} -``` - ---- - -#### Get Azure Token - -**Endpoint:** -```http -POST /api/speechServices/azureToken -``` - -**Request (Protobuf):** -```protobuf -message GenerateAzureTokenReq { - string userSid = 1; -} -``` - ---- - -### 📁 File Upload & Whiteboard - -#### Upload File (Resumable) - -برای آپلود فایل‌های بزرگ به صورت chunk به chunk. - -**Endpoint:** -```http -POST /api/fileUpload?resumable=true&roomSid=xxx&roomId=xxx&userId=xxx -``` - -**Headers:** -```http -Authorization: -Content-Type: multipart/form-data -``` - -**Response:** `part_uploaded` or error - ---- - -#### Merge Uploaded Chunks - -**Endpoint:** -```http -POST /api/uploadedFileMerge -``` - -**Request (JSON):** -```json -{ - "roomSid": "RM_xxxxxxxxxxxx", - "roomId": "algebra-class-1402", - "resumableIdentifier": "unique-file-id", - "resumableFilename": "document.pdf", - "resumableTotalChunks": 10 -} -``` - -**Response (JSON):** -```json -{ - "status": true, - "msg": "file merged successfully", - "filePath": "/uploads/document.pdf", - "fileName": "document.pdf", - "fileExtension": "pdf" -} -``` - ---- - -#### Convert Whiteboard File - -فایل‌های PDF/Office را به تصاویر برای وایت‌برد تبدیل می‌کند. - -> **پیش‌نیاز**: `libreoffice` و `mupdf-tools` (mutool) باید روی سرور نصب باشند. - -**Endpoint:** -```http -POST /api/convertWhiteboardFile -``` - -**Request (JSON):** -```json -{ - "roomSid": "RM_xxxxxxxxxxxx", - "roomId": "algebra-class-1402", - "filePath": "/uploads/document.pdf", - "userId": "teacher-123" -} -``` - -**Response (JSON):** -```json -{ - "status": true, - "msg": "file converted successfully", - "fileName": "document", - "fileId": "file_xxxxxxxxxxxx", - "filePath": "/whiteboard/document/", - "totalPages": 15 -} -``` - ---- - -## 🔧 Other Services - -### 🔔 Webhook - -برای دریافت رویدادهای LiveKit. - -**Endpoint:** -```http -POST /webhook -``` - -**Headers:** -```http -Authorization: -``` - -**Webhook Events:** -- `room_started` - شروع روم -- `room_finished` - پایان روم -- `participant_joined` - ورود کاربر -- `participant_left` - خروج کاربر -- `track_published` - انتشار track -- `track_unpublished` - حذف track -- `recording_started` - شروع ضبط -- `recording_finished` - پایان ضبط -- و بیشتر... - ---- - -### ❤️ Health Check - -وضعیت سلامت سرور را بررسی می‌کند. - -**Endpoint:** -```http -GET /healthCheck -``` - -**Response:** -``` -Healthy -``` - -سرویس‌های بررسی شده: -- ✅ Database (MySQL/MariaDB) -- ✅ Redis -- ✅ NATS - ---- - -### 📥 Download Services - -#### Download Uploaded File -```http -GET /download/uploadedFile/:sid/* -``` - -#### Download Recording -```http -GET /download/recording/:token -``` - -#### Download Analytics -```http -GET /download/analytics/:token -``` - ---- - -## 🔄 Compatibility APIs - -### 🟦 BigBlueButton (BBB) Compatibility - -Plugnmeet از API های BigBlueButton پشتیبانی می‌کند. - -**Base Path:** -``` -/:apiKey/bigbluebutton/api -``` - -**Available Endpoints:** -- `GET/POST /create` - ایجاد جلسه -- `GET/POST /join` - ورود به جلسه -- `GET/POST /isMeetingRunning` - بررسی فعال بودن -- `GET/POST /getMeetingInfo` - اطلاعات جلسه -- `GET/POST /getMeetings` - لیست جلسات -- `GET/POST /end` - پایان جلسه -- `GET/POST /getRecordings` - لیست ضبط‌ها -- `GET/POST /deleteRecordings` - حذف ضبط -- `GET/POST /publishRecordings` - انتشار ضبط -- `GET/POST /updateRecordings` - به‌روزرسانی ضبط - -**Authentication:** نیازمند `checksum` مطابق استاندارد BBB - -#### Example (BBB Join) -```bash -API_KEY="your-api-key" -SECRET="your-secret" -MEETING_ID="test-meeting" -USER_NAME="Ali" - -# ساخت query string -QUERY="meetingID=${MEETING_ID}&fullName=${USER_NAME}" - -# محاسبه checksum -CHECKSUM=$(echo -n "join${QUERY}${SECRET}" | sha1sum | awk '{print $1}') - -# URL نهایی -URL="https://your-domain.com/${API_KEY}/bigbluebutton/api/join?${QUERY}&checksum=${CHECKSUM}" - -echo "Join URL: $URL" -``` - ---- - -### 🎓 LTI (Learning Tools Interoperability) - -برای یکپارچگی با سیستم‌های LMS. - -**Base Path:** -``` -/lti/v1 -``` - -#### LTI Landing -```http -POST /lti/v1 -``` - -#### LTI API Endpoints - -نیازمند هدر `Authorization` خاص LTI: - -- `POST /lti/v1/api/room/join` - ورود به روم -- `POST /lti/v1/api/room/isActive` - بررسی فعال بودن -- `POST /lti/v1/api/room/end` - پایان روم -- `POST /lti/v1/api/recording/fetch` - لیست ضبط‌ها -- `POST /lti/v1/api/recording/download` - دانلود ضبط -- `POST /lti/v1/api/recording/delete` - حذف ضبط - ---- - -## 🛠️ SDKs & Tools - -### Official SDKs - -#### PHP SDK -```bash -composer require mynaparrot/plugnmeet-sdk-php -``` - -```php -roomId = 'test-room'; -$params->metadata->roomTitle = 'کلاس آزمایشی'; - -$result = $plugnmeet->room->create($params); -``` - ---- - -#### JavaScript/Node.js SDK -```bash -npm install plugnmeet-sdk-js -``` - -```javascript -const { PlugNmeet } = require('plugnmeet-sdk-js'); - -const plugnmeet = new PlugNmeet({ - host: 'https://your-domain.com', - apiKey: 'your-api-key', - apiSecret: 'your-secret' -}); - -// ایجاد روم -const result = await plugnmeet.room.create({ - roomId: 'test-room', - metadata: { - roomTitle: 'کلاس آزمایشی' - } -}); - -// تولید توکن ورود -const token = await plugnmeet.room.getJoinToken({ - roomId: 'test-room', - userInfo: { - userId: 'user-123', - name: 'علی احمدی', - isAdmin: false - } -}); -``` - ---- - -### Docker Deployment - -```bash -docker run -d \ - --name plugnmeet-server \ - -p 8080:8080 \ - -v $PWD/config.yaml:/config.yaml \ - mynaparrot/plugnmeet-server \ - --config /config.yaml -``` - ---- - -## 📚 Additional Resources - -### Documentation -- 🌐 **Official Website**: https://www.plugnmeet.org -- 📖 **Full Documentation**: https://www.plugnmeet.org/docs -- 🔧 **Installation Guide**: https://www.plugnmeet.org/docs/installation -- 👨‍💻 **Developer Guide**: https://www.plugnmeet.org/docs/developer-guide - -### Community & Support -- 💬 **Discord**: https://discord.gg/2X2ZaCHu4C -- 🐛 **GitHub Issues**: https://github.com/mynaparrot/plugNmeet-server/issues -- 📧 **Email Support**: support@plugnmeet.com - -### Source Code -- 🖥️ **Server**: https://github.com/mynaparrot/plugNmeet-server -- 🎨 **Client**: https://github.com/mynaparrot/plugNmeet-client -- 🎬 **Recorder**: https://github.com/mynaparrot/plugNmeet-recorder - ---- - -## 📝 Notes & Best Practices - -### Performance Tips -1. ✅ از Redis برای caching استفاده کنید -2. ✅ برای مقیاس‌پذیری از Load Balancer استفاده کنید -3. ✅ ضبط‌ها را در storage خارجی (S3, MinIO) ذخیره کنید -4. ✅ از CDN برای سرویس‌دهی فایل‌های استاتیک استفاده کنید - -### Security Best Practices -1. 🔒 HTTPS را فعال کنید (الزامی) -2. 🔒 `apiKey` و `secret` را محرمانه نگه دارید -3. 🔒 از CORS Policy مناسب استفاده کنید -4. 🔒 توکن‌ها را با expiration time محدود تولید کنید -5. 🔒 Webhook signature را همیشه تایید کنید - -### Rate Limiting -- `/auth` endpoints: 100 req/min per IP -- `/api` endpoints: 1000 req/min per token -- File uploads: 10 MB/s per user - ---- - -## 🎯 Quick Start Checklist - -- [ ] LiveKit Server راه‌اندازی شده -- [ ] Redis نصب و پیکربندی شده -- [ ] MySQL/MariaDB آماده است -- [ ] فایل `config.yaml` تنظیم شده -- [ ] Plugnmeet Server در حال اجراست -- [ ] Client UI در دسترس است -- [ ] Test meeting ایجاد و تست شده -- [ ] Webhook تنظیم شده (اختیاری) -- [ ] Recording تست شده (اختیاری) - ---- - -## 🎉 نسخه و تاریخچه تغییرات - -**نسخه فعلی مستند**: 2.0.0 -**آخرین به‌روزرسانی**: نوامبر 2024 - -برای مشاهده تاریخچه کامل تغییرات به فایل [CHANGELOG.md](./CHANGELOG.md) مراجعه کنید. - ---- - -
- -**ساخته شده با ❤️ توسط [MynaParrot](https://www.mynaparrot.com)** - -[Website](https://www.plugnmeet.org) • [GitHub](https://github.com/mynaparrot/plugNmeet-server) • [Discord](https://discord.gg/2X2ZaCHu4C) - -
diff --git a/apps/course/services/plugnmeet.py b/apps/course/services/plugnmeet.py deleted file mode 100644 index 29d0072..0000000 --- a/apps/course/services/plugnmeet.py +++ /dev/null @@ -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"] diff --git a/apps/course/signals.py b/apps/course/signals.py deleted file mode 100644 index b83ac22..0000000 --- a/apps/course/signals.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/apps/course/templates/course/add_student_form.html b/apps/course/templates/course/add_student_form.html deleted file mode 100644 index ecd1d31..0000000 --- a/apps/course/templates/course/add_student_form.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "admin/base_site.html" %} - -{% load i18n unfold %} - -{% block breadcrumbs %}{% endblock %} - -{% block extrahead %} - {{ block.super }} - - {{ form.media }} -{% endblock %} - -{% block content %} -
-
- {% csrf_token %} - - {% for field in form %} - {% include "unfold/helpers/field.html" with field=field %} - {% endfor %} -
- -
- {% component "unfold/components/button.html" with submit=1 %} - {% trans "Submit form" %} - {% endcomponent %} -
-
-{% endblock %} diff --git a/apps/course/tests.py b/apps/course/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/course/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/course/tests/__init__.py b/apps/course/tests/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/apps/course/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/course/tests/test_live_session_api.py b/apps/course/tests/test_live_session_api.py deleted file mode 100644 index 54ccac8..0000000 --- a/apps/course/tests/test_live_session_api.py +++ /dev/null @@ -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']) diff --git a/apps/course/tests/test_multiple_roles_api.py b/apps/course/tests/test_multiple_roles_api.py deleted file mode 100644 index 12be84d..0000000 --- a/apps/course/tests/test_multiple_roles_api.py +++ /dev/null @@ -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) diff --git a/apps/course/tests/test_professor_api.py b/apps/course/tests/test_professor_api.py deleted file mode 100644 index 72bd79d..0000000 --- a/apps/course/tests/test_professor_api.py +++ /dev/null @@ -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) diff --git a/apps/course/token-join-guide.md b/apps/course/token-join-guide.md deleted file mode 100644 index 9e0e157..0000000 --- a/apps/course/token-join-guide.md +++ /dev/null @@ -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//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 - 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= -``` - -## گام ۳: ورود کلاینت با توکن -۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئری‌استرینگ یا از کوکی `pnm_access_token` می‌خواند (`getAccessToken` در `client/src/helpers/utils.ts`). -۲. آدرس ورود: `https://meet.newhorizonco.uk/?access_token=`. -۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری: - - درخواست `POST /api/verifyToken` را با هدر `Authorization: ` می‌فرستد (`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 ` در 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 فعال است diff --git a/apps/course/urls.py b/apps/course/urls.py deleted file mode 100644 index 8028538..0000000 --- a/apps/course/urls.py +++ /dev/null @@ -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[\w-]+)/courses/$', views.ProfessorCourseListAPIView.as_view(), name='course-professor-course-list'), - re_path(r'professors/(?P[\w-]+)/$', views.ProfessorDetailAPIView.as_view(), name='course-professor-detail'), - path('/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'), - re_path(r'(?P[\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[\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('/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[\w-]+)/$', views.CourseDetailAPIView.as_view(), name='course-detail'), - re_path(r'(?P[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), - re_path(r'(?P[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), - re_path(r'(?P[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'), - path('lesson//', views.LessonDetailView.as_view(), name='lesson-detail'), - - re_path(r'(?P[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'), - - - # path('/participant/join/', views.ParticipantCreateView.as_view(), name='course-participant-join'), - -] diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py deleted file mode 100644 index 70ef373..0000000 --- a/apps/course/views/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .course import * -from .lesson import * -from .participant import * -from .professor import * -from .live_session import * -from .webhook import * diff --git a/apps/course/views/course.py b/apps/course/views/course.py deleted file mode 100644 index 5b90d01..0000000 --- a/apps/course/views/course.py +++ /dev/null @@ -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 ", - type=openapi.TYPE_BOOLEAN, - ), - openapi.Parameter( - 'is_online', openapi.IN_QUERY, - description="Статус участия is_online ", - type=openapi.TYPE_BOOLEAN, - ), - ]) - def get(self, request, *args, **kwargs): - return 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 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}") \ No newline at end of file diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py deleted file mode 100644 index 520586d..0000000 --- a/apps/course/views/lesson.py +++ /dev/null @@ -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 - ) diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py deleted file mode 100644 index 62bee4f..0000000 --- a/apps/course/views/live_session.py +++ /dev/null @@ -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) diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py deleted file mode 100644 index 58e3860..0000000 --- a/apps/course/views/participant.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/apps/course/views/professor.py b/apps/course/views/professor.py deleted file mode 100644 index 2cdf251..0000000 --- a/apps/course/views/professor.py +++ /dev/null @@ -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) diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py deleted file mode 100644 index de20185..0000000 --- a/apps/course/views/webhook.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/apps/dobodbi_calendar/tests.py b/apps/dobodbi_calendar/tests.py index 7ce503c..13eb8b1 100644 --- a/apps/dobodbi_calendar/tests.py +++ b/apps/dobodbi_calendar/tests.py @@ -1,3 +1,22 @@ -from django.test import TestCase +from rest_framework.test import APITestCase -# Create your tests here. + +class CalendarURLResolutionTests(APITestCase): + """ + Test suite to ensure all calendar API endpoints resolve and execute cleanly via absolute paths. + """ + + def test_sync_occasions_endpoint(self): + """Test sync occasions endpoint is accessible""" + response = self.client.get('/api/calendar/sync-occasions/') + self.assertLess(response.status_code, 500) + + def test_adjustemnts_endpoint(self): + """Test adjustments config endpoint is accessible""" + response = self.client.get('/api/calendar/adjustemnts/') + self.assertLess(response.status_code, 500) + + def test_occasions_endpoint(self): + """Test occasions list endpoint is accessible""" + response = self.client.get('/api/calendar/occasions/') + self.assertLess(response.status_code, 500) diff --git a/apps/dobodbi_calendar/views.py b/apps/dobodbi_calendar/views.py index d66faab..00299ac 100644 --- a/apps/dobodbi_calendar/views.py +++ b/apps/dobodbi_calendar/views.py @@ -95,7 +95,10 @@ class AdjustmentConfigView(APIView): ) def get(self, request): adjustment_config = get_config('calendar__Adjustment') - return Response(json.loads(adjustment_config)) + try: + return Response(json.loads(adjustment_config) if adjustment_config else {}) + except Exception: + return Response({}) class OccasionsList(ListAPIView): serializer_class = CalendarSerializer diff --git a/apps/library/tests.py b/apps/library/tests.py index 7ce503c..6b3dde5 100644 --- a/apps/library/tests.py +++ b/apps/library/tests.py @@ -1,3 +1,52 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status -# Create your tests here. + +class LibraryURLResolutionTests(APITestCase): + """ + Test suite to ensure all library API endpoints resolve and execute cleanly. + """ + + def test_category_list_endpoint(self): + """Test categories list endpoint is accessible""" + url = reverse('category-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_pinned_collection_list_endpoint(self): + """Test pinned collections endpoint is accessible""" + url = reverse('pinned-collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_collection_list_endpoint(self): + """Test collections endpoint is accessible""" + url = reverse('collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_book_list_endpoint(self): + """Test books list endpoint is accessible""" + url = reverse('book-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_book_detail_endpoint(self): + """Test book detail endpoint is accessible (may return 404 if no data)""" + url = reverse('book-detail', kwargs={'slug': 'test-book'}) + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_downloaded_books_list_endpoint(self): + """Test downloaded books list endpoint is accessible""" + url = reverse('downloaded-books-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_book_download_endpoint(self): + """Test book download endpoint is accessible (POST)""" + url = reverse('book-download') + # Hitting POST endpoint with GET or empty POST should resolve safely without throwing 500 + response = self.client.post(url) + self.assertLess(response.status_code, 500) diff --git a/apps/podcast/tests.py b/apps/podcast/tests.py index 7ce503c..1049d10 100644 --- a/apps/podcast/tests.py +++ b/apps/podcast/tests.py @@ -1,3 +1,50 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase -# Create your tests here. + +class PodcastURLResolutionTests(APITestCase): + """ + Test suite to ensure all podcast API endpoints resolve and execute cleanly. + """ + + def test_category_list_endpoint(self): + """Test podcast categories endpoint is accessible""" + url = reverse('podcast:category-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_pinned_collection_list_endpoint(self): + """Test podcast pinned collections endpoint is accessible""" + url = reverse('podcast:pinned-collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_collection_list_endpoint(self): + """Test podcast collections endpoint is accessible""" + url = reverse('podcast:collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_podcast_list_endpoint(self): + """Test podcast list endpoint is accessible""" + url = reverse('podcast:podcast-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_podcast_detail_endpoint(self): + """Test podcast detail endpoint is accessible (may return 404 if no data)""" + url = reverse('podcast:podcast-detail', kwargs={'slug': 'test-podcast'}) + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_user_playlist_create_endpoint(self): + """Test user playlist create endpoint is accessible (POST)""" + url = reverse('podcast:user-playlist-create') + response = self.client.post(url) + self.assertLess(response.status_code, 500) + + def test_user_playlist_list_endpoint(self): + """Test user playlist list endpoint is accessible""" + url = reverse('podcast:user-playlist-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) diff --git a/apps/quiz/__init__.py b/apps/quiz/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/quiz/admin/__init__.py b/apps/quiz/admin/__init__.py deleted file mode 100644 index 42092ad..0000000 --- a/apps/quiz/admin/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .quiz import * -from .question import * -from .participant import * -# from .prize import * -# from .user_rank_quiz import * \ No newline at end of file diff --git a/apps/quiz/admin/participant.py b/apps/quiz/admin/participant.py deleted file mode 100644 index 0756e30..0000000 --- a/apps/quiz/admin/participant.py +++ /dev/null @@ -1,111 +0,0 @@ -from django.contrib import admin -from django.db.models import F -from django.contrib.admin import SimpleListFilter -from django.utils.translation import gettext_lazy as _ -from django import forms - -from unfold.admin import ModelAdmin, StackedInline, TabularInline -from unfold.decorators import display - -from apps.quiz.models import QuizParticipant, ParticipantAnswer -from apps.account.models import User - -from utils.admin import project_admin_site - -# --- INLINE FOR QUIZ DETAIL PAGE --- -class MinWidthInlineForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - target_dropdown_fields = ['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 QuizParticipantInline(TabularInline): - model = QuizParticipant - form = MinWidthInlineForm - extra = 0 - tab = True - fields = ('user', 'started_at', 'total_timing', 'total_score') - readonly_fields = ('user', 'started_at', 'total_timing', 'total_score') - autocomplete_fields = ('user',) - show_change_link = True - verbose_name = _("Recent Participant") - verbose_name_plural = _("Recent Participants (Latest 10)") - - 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(quiz_id=object_id).order_by('-started_at').values_list('id', flat=True)[:10]) - return qs.filter(id__in=latest_ids).order_by('-started_at') - 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 ParticipantAnswerInline(StackedInline): - model = ParticipantAnswer - readonly_fields = ( - 'correct_answer_display', 'question', 'at_time', 'answer_timing', - ) - - @display(description=_("Correct Answer")) - - def correct_answer_display(self, obj): - return obj.correct_answer - - def has_add_permission(self, request, obj): - return False - - def has_delete_permission(self, request, obj=None): - return False - - def get_queryset(self, request): - return super().get_queryset(request).annotate(correct_answer=F('question__correct_answer')) - - -class UserEmailFilter(SimpleListFilter): - title = _('User Email') - parameter_name = 'user_email' - - def lookups(self, request, model_admin): - # 🔔 FILTER: Only fetch active users who have an email (not guests or deleted) - users = User.objects.filter(is_active=True, email__isnull=False) - return [(user.email, user.email) for user in users] - - def queryset(self, request, queryset): - if self.value(): - email = self.value().replace('%40', '@') - return queryset.filter(user__email=email) - return queryset - - -class ParticipantAdmin(ModelAdmin): - inlines = [ParticipantAnswerInline] - search_fields = ['user__username', 'user__fullname'] - list_display = [ - 'quiz', 'user', 'started_at', 'ended_at', 'total_timing', - 'question_score', 'timing_score', 'total_score' - ] - list_filter = ['started_at', 'ended_at', 'quiz__status', UserEmailFilter] - - # Optional: Add these for better UI experience - date_hierarchy = 'started_at' - ordering = ['-started_at'] - - # 🔔 FILTER: Restrict the user dropdown in forms to active, non-guest users - 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) - -project_admin_site.register(QuizParticipant, ParticipantAdmin) \ No newline at end of file diff --git a/apps/quiz/admin/question.py b/apps/quiz/admin/question.py deleted file mode 100644 index dacebac..0000000 --- a/apps/quiz/admin/question.py +++ /dev/null @@ -1,85 +0,0 @@ -from django import forms -from django.contrib import admin - -from unfold.admin import ModelAdmin, TabularInline, StackedInline -from unfold.forms import forms - -from apps.quiz.models import Question - -from utils.admin import project_admin_site - - - -# Uncomment if you want to register Question as a standalone admin -# @admin.register(Question) -# class QuestionAdmin(ModelAdmin): -# list_display = ('question', 'correct_answer', 'quiz', 'priority') -# form = QuestionAdminForm -# ordering = ("priority", "id",) -# fieldsets = ( -# ( -# None, { -# 'fields': ( -# 'question', -# ('option1', 'option2'), -# ('option3', 'option4'), -# 'correct_answer', -# ) -# }, -# ), -# ( -# None, { -# 'fields': ('priority',) -# } -# ) -# ) -@admin.register(Question) -class QuestionAdmin(ModelAdmin): - list_display = ('question', 'correct_answer', 'quiz', 'priority') - ordering = ("priority", "id",) - search_fields = ('question', 'quiz__title') - list_filter = ('quiz',) - - fieldsets = ( - ( - None, { - 'fields': ( - 'quiz', - 'question', - ('option1', 'option2'), - ('option3', 'option4'), - 'correct_answer', - ) - }, - ), - ( - None, { - 'fields': ('priority',) - } - ) - ) - -class QuestionAdminInline(StackedInline): - model = Question - ordering = ("priority", "id",) - extra = 0 - - fieldsets = ( - ( - None, { - 'fields': ( - 'question', - ('option1', 'option2'), - ('option3', 'option4'), - 'correct_answer', - ) - }, - ), - ( - None, { - 'fields': ('priority',) - } - ) - ) -project_admin_site.register(Question, QuestionAdmin) - diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py deleted file mode 100644 index db0dde2..0000000 --- a/apps/quiz/admin/quiz.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.contrib import admin, messages -from django.db.models import Count -from django.utils.safestring import mark_safe -from django.urls import reverse -from django.shortcuts import redirect -from django.utils.translation import gettext_lazy as _ - -from unfold.admin import ModelAdmin -from unfold.decorators import display, action - -from apps.course.models import CourseLesson -from apps.quiz.models import Quiz -from apps.quiz.admin.question import QuestionAdminInline -from apps.quiz.admin.participant import QuizParticipantInline -from utils.admin import project_admin_site, admin_url_generator - - -class QuizAdmin(ModelAdmin): - search_fields = ['title', 'lesson__title'] - list_display = ['title', 'description', 'lesson', 'each_question_timing', 'status_display', 'questions_display'] - list_filter = ['each_question_timing', 'status'] - inlines = [QuestionAdminInline, QuizParticipantInline] - compressed_fields = True - - # 🔔 ADD THE TOP ACTION BUTTON - actions_detail = ['manage_all_participants','go_to_course'] - - def get_queryset(self, request): - queryset = super().get_queryset(request).annotate( - questions_count=Count('questions') - ) - - # اولویت اول: staff یا admin - دسترسی کامل - if (request.user.is_staff or - request.user.has_role('admin') or - request.user.has_role('super_admin')): - return queryset - - # اولویت دوم: professor - فقط کوئیزهای دوره‌های خود - if request.user.has_role('professor'): - return queryset.filter(lesson__course__professor=request.user) - - return queryset.none() - - def get_exclude(self, request, obj=None): - if not obj: # اگر obj وجود ندارد یعنی صفحه Add است - return ['course'] - return [] - - # 🔔 قفل کردن (Readonly) فیلد کورس در زمان مشاهده و ویرایش - def get_readonly_fields(self, request, obj=None): - if obj: # اگر obj وجود دارد یعنی صفحه Change/Detail است - return ['course'] - return [] - - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - - # محدود کردن انتخاب lesson بر اساس سطح دسترسی کاربر - if (request.user.is_staff or - request.user.has_role('admin') or - request.user.has_role('super_admin')): - # اولویت اول: staff یا admin - دسترسی کامل - form.base_fields['lesson'].queryset = CourseLesson.objects.all() - elif request.user.has_role('professor'): - # اولویت دوم: professor - فقط CourseLesson های دوره‌های خود - form.base_fields['lesson'].queryset = CourseLesson.objects.filter(course__professor=request.user) - else: - # سایر کاربران - عدم دسترسی - form.base_fields['lesson'].queryset = CourseLesson.objects.none() - - form.base_fields['lesson'].widget.can_add_related = False - - return form - - @display(description=_('Status'), ordering='status') - def status_display(self, obj): - if obj.status: - return mark_safe(f'{_("Active")}') - return mark_safe(f'{_("Inactive")}') - - @display(description=_('Questions'), ordering='questions_count') - def questions_display(self, obj): - url = reverse('admin:quiz_question_changelist') + f'?quiz={obj.id}' - return mark_safe(f'{_("Questions")}: {obj.questions_count}') - - # 🔔 THE REDIRECT LOGIC FOR THE NEW BUTTON - @action( - description=_("Manage All Participants"), - icon="groups", - ) - def manage_all_participants(self, request, object_id): - """Redirect to the pre-filtered Quiz Participant changelist for this quiz.""" - quiz = self.get_object(request, object_id) - if not quiz: - messages.error(request, _("Quiz not found")) - return redirect(admin_url_generator(request, "quiz_quiz_changelist")) - - # Generate base URL for quiz participant list - base_url = admin_url_generator(request, "quiz_quizparticipant_changelist") - - # Append the filter query parameter - url = f"{base_url}?quiz__id__exact={object_id}" - return redirect(url) - - @action( - description=_("View Course"), - icon="school", # آیکون کلاه فارغ‌التحصیلی - ) - def go_to_course(self, request, object_id): - """دکمه‌ای برای رفتن به صفحه جزئیات کورسِ این کوییز""" - quiz = self.get_object(request, object_id) - - # پیدا کردن کورس از طریق درس (Lesson) - if quiz and quiz.lesson and quiz.lesson.course_id: - url = reverse('admin:course_course_change', args=[quiz.lesson.course_id]) - return redirect(url) - - messages.error(request, _("Course not found for this quiz.")) - return redirect(request.META.get('HTTP_REFERER', '/')) - -project_admin_site.register(Quiz, QuizAdmin) \ No newline at end of file diff --git a/apps/quiz/admin/user_rank_quiz.py b/apps/quiz/admin/user_rank_quiz.py deleted file mode 100644 index e6b988d..0000000 --- a/apps/quiz/admin/user_rank_quiz.py +++ /dev/null @@ -1,132 +0,0 @@ -# import calendar -# from django.utils import timezone - -# from ajaxdatatable.admin import AjaxDatatable -# from django.contrib import admin -# from django.utils.translation import gettext_lazy as _ -# from django.contrib.admin import SimpleListFilter -# from django.db.models.functions import Rank, Coalesce -# from django.db.models import Sum, F, Window, CharField -# from django.utils.html import format_html -# from apps.quiz.models import Quiz, QuizRankUser, Participant, QuizCategory -# from apps.account.models import User - - -# class QuizFilter(SimpleListFilter): -# title = _('Quiz') -# parameter_name = 'quiz' - -# def lookups(self, request, model_admin): -# quizzes = Quiz.objects.all() -# return [(quiz.id, quiz.video.title) for quiz in quizzes] - -# def queryset(self, request, queryset): -# if self.value(): -# return queryset.filter(uquizzes__quiz__id=self.value()) -# return queryset - -# class QuizCategoryFilter(SimpleListFilter): -# title = _('Quiz Category') -# parameter_name = 'quiz_category' - -# def lookups(self, request, model_admin): -# categories = QuizCategory.objects.all() -# return [(category.id, category.name) for category in categories] - -# def queryset(self, request, queryset): -# if self.value(): -# return queryset.filter(uquizzes__quiz__category__id=self.value()) -# return queryset - -# class MonthFilter(SimpleListFilter): -# title = _('Month') -# parameter_name = 'month' - -# def lookups(self, request, model_admin): -# return [(str(i), calendar.month_name[i]) for i in range(1, 13)] - -# def queryset(self, request, queryset): -# if self.value(): -# month = int(self.value()) -# year = timezone.now().year -# return queryset.filter(uquizzes__started_at__year=year, uquizzes__started_at__month=month) -# return queryset - - -# @admin.register(QuizRankUser) -# class QuizRankUserAdmin(AjaxDatatable): -# list_display = ('username_link', 'get_total_score', 'get_rank') -# list_filter = (QuizFilter, QuizCategoryFilter, MonthFilter) -# readonly_fields = ('date_joined', 'last_login') - - -# def get_queryset(self, request): -# queryset = super().get_queryset(request) - -# quiz_id = request.GET.get('quiz') -# category_id = request.GET.get('quiz_category') -# month = request.GET.get('month') - -# filters = {} -# if quiz_id: -# filters['uquizzes__quiz_id'] = quiz_id -# if category_id: -# filters['uquizzes__quiz__category_id'] = category_id -# if month: -# month = int(month) -# year = timezone.now().year -# filters['uquizzes__started_at__year'] = year -# filters['uquizzes__started_at__month'] = month - -# if filters: -# queryset = queryset.filter(**filters) - -# users_scores = Participant.objects.filter(**{k.replace('uquizzes__', ''): v for k, v in filters.items()}).select_related('user').values( -# username=Coalesce(F('user__username'), F('user__email'), output_field=CharField()) -# ).annotate( -# score=Sum('total_score') -# ).order_by('-score') - -# # Add rank to each user using window function -# users_scores = users_scores.annotate( -# rank=Window( -# expression=Rank(), -# order_by=F('score').desc() -# ) -# ).order_by("rank") - -# user_scores_dict = {user['username']: user for user in users_scores} -# for user in queryset: -# user.score = user_scores_dict.get(user.username, {}).get('score', 0) -# user.rank = user_scores_dict.get(user.username, {}).get('rank', 'N/A') -# self.queryset = queryset -# return queryset - -# def has_view_permission(self, request, obj=None): -# return True - -# def has_change_permission(self, request, obj=None): -# return False - -# def has_add_permission(self, request): -# return False - -# def has_delete_permission(self, request, obj=None): -# return False - -# def username_link(self, obj): -# return format_html('{}', obj.id, obj.username) -# username_link.short_description = 'Username' -# username_link.admin_order_field = 'username' - -# def get_total_score(self, obj): -# for user in self.queryset: -# if user.id == obj.id: -# return user.score -# get_total_score.short_description = 'Total Score' - -# def get_rank(self, obj): -# for user in self.queryset: -# if user.id == obj.id: -# return user.rank -# get_rank.short_description = 'Rank' diff --git a/apps/quiz/apps.py b/apps/quiz/apps.py deleted file mode 100644 index 519127d..0000000 --- a/apps/quiz/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class QuizConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.quiz' diff --git a/apps/quiz/doc.py b/apps/quiz/doc.py deleted file mode 100644 index 7099d7e..0000000 --- a/apps/quiz/doc.py +++ /dev/null @@ -1,131 +0,0 @@ -def doc_quiz_submit(): - return """ -# 📝 ارسال پاسخ‌های کوییز - -این API برای ثبت شرکت کاربر در کوییز و ارسال پاسخ‌های مربوطه استفاده می‌شود. زمانی که کاربر در کوییز شرکت می‌کند، باید به همراه پاسخ‌های خود، زمان پاسخ‌دهی و اطلاعات دیگر را ارسال نماید. در این API، کاربر نمی‌تواند دوباره در همان کوییز شرکت کند. - ---- - -## 📄 توضیحات مقادیر پاسخ - -| کلید | نوع داده | توضیحات | -|------------------------|-----------------|---------------------------------------------------------| -| `quiz` | Integer | شناسه کوییز که کاربر در آن شرکت کرده است. | -| `started_at` | DateTime | زمان شروع کوییز. | -| `ended_at` | DateTime | زمان پایان کوییز. | -| `total_timing` | Integer | مدت زمان کلی که کاربر برای پاسخ‌دهی به کوییز صرف کرده است.| -| `question_score` | Integer | امتیاز به‌دست‌آمده توسط کاربر در پاسخ به سوالات کوییز. | -| `timing_score` | Integer | امتیاز به‌دست‌آمده توسط کاربر بر اساس زمان پاسخ‌دهی. | -| `total_score` | Integer | امتیاز کلی کاربر در کوییز (ترکیب امتیاز سوالات و زمان).| -| `answers` | Array | لیستی از پاسخ‌های کاربر به سوالات. | -| `answers.question` | Integer | شناسه سوالی که کاربر به آن پاسخ داده است. | -| `answers.option_num` | Integer | شماره گزینه‌ای که کاربر انتخاب کرده است. | -| `answers.at_time` | DateTime | زمانی که کاربر پاسخ به سوال را ارسال کرده است. | -| `answers.answer_timing`| Integer | مدت زمان پاسخ‌دهی به سوال (در ثانیه). | - ---- -## errors: -# کاربر از قبل کوعیز را شرکت کرده است -```json -{ - "status": "error", - "code": "validation_error", - "status_code": 400, - "message": "There were validation errors.", - "errors": [ - { - "field": "quiz", - "message": "you have already participated in the quiz" - } - ] -} -‍``` - ---- -## پاسخ موفق (201 Created) - -در صورتی که ثبت‌نام موفقیت‌آمیز باشد و پاسخ‌ها ذخیره شوند، یک شیء JSON مشابه با نمونه زیر برگشت داده می‌شود: - -### پاسخ: -```json -{ - "quiz": 1, - "started_at": "2024-11-29T12:00:00Z", - "ended_at": "2024-11-29T12:30:00Z", - "total_timing": 1800, - "question_score": 80, - "timing_score": 10, - "total_score": 90, - "answers": [ - { - "question": 1, - "option_num": 3, - "at_time": "2024-11-29T12:05:00Z", - "answer_timing": 30 - }, - { - "question": 2, - "option_num": 1, - "at_time": "2024-11-29T12:15:00Z", - "answer_timing": 45 - } - ] -} -""" - -def doc_quiz_detail(): - return """ -# 📋 Quiz Detail API - -با ایدی درس میتواند وارد یک کوعیز شوید -این api -سوالات کوعیز و جزعیاتش را برمیگرداند - - -## URL -`GET /path//` - -## پارامترها - -- `lesson_id`: شناسه درس برای دریافت کوییز مرتبط. - -## پاسخ موفق (200 OK) - -در صورتی که درس دارای کوییز باشد، یک شیء JSON با اطلاعات کوییز برگشت داده می‌شود. - -### پاسخ: - -```json -{ - "id": 1, - "permission": true, - "lesson": 101, - "title": "Quiz on Python Basics", - "description": "A quiz on the basics of Python programming.", - "each_question_timing": 30, - "questions": [ - { - "id": 1, - "question": "What is the output of print(2 + 3)?", - "options": [ - {"id": 1, "title": "5"}, - {"id": 2, "title": "6"}, - {"id": 3, "title": "7"}, - {"id": 4, "title": "8"} - ], - "correct_answer": 1 - }, - { - "id": 2, - "question": "What is the result of 2 * 3?", - "options": [ - {"id": 1, "title": "6"}, - {"id": 2, "title": "5"}, - {"id": 3, "title": "7"}, - {"id": 4, "title": "8"} - ], - "correct_answer": 1 - } - ] -} -""" diff --git a/apps/quiz/management/__init__.py b/apps/quiz/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/quiz/management/commands/__init__.py b/apps/quiz/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/quiz/management/commands/clear_quiz_data.py b/apps/quiz/management/commands/clear_quiz_data.py deleted file mode 100644 index c3a0192..0000000 --- a/apps/quiz/management/commands/clear_quiz_data.py +++ /dev/null @@ -1,78 +0,0 @@ -from django.core.management.base import BaseCommand -from django.db import transaction - -from apps.quiz.models import Quiz, Question, QuizParticipant, ParticipantAnswer - - -class Command(BaseCommand): - help = 'Clear all quiz-related data from the database' - - def add_arguments(self, parser): - parser.add_argument( - '--confirm', - action='store_true', - help='Confirm that you want to delete all quiz data', - ) - - def handle(self, *args, **options): - if not options['confirm']: - self.stdout.write( - self.style.WARNING( - 'This command will delete ALL quiz-related data from the database!\n' - 'This includes:\n' - '- All Quizzes\n' - '- All Questions\n' - '- All Quiz Participants\n' - '- All Participant Answers\n\n' - 'Use --confirm flag to proceed with deletion.\n' - 'Example: python manage.py clear_quiz_data --confirm' - ) - ) - return - - try: - with transaction.atomic(): - # Count records before deletion - participant_answers_count = ParticipantAnswer.objects.count() - quiz_participants_count = QuizParticipant.objects.count() - questions_count = Question.objects.count() - quizzes_count = Quiz.objects.count() - - self.stdout.write( - f'Found {participant_answers_count} participant answers, ' - f'{quiz_participants_count} quiz participants, ' - f'{questions_count} questions, and ' - f'{quizzes_count} quizzes.' - ) - - # Delete in order to respect foreign key constraints - # ParticipantAnswer -> QuizParticipant -> Quiz - # Question -> Quiz - - self.stdout.write('Deleting participant answers...') - ParticipantAnswer.objects.all().delete() - - self.stdout.write('Deleting quiz participants...') - QuizParticipant.objects.all().delete() - - self.stdout.write('Deleting questions...') - Question.objects.all().delete() - - self.stdout.write('Deleting quizzes...') - Quiz.objects.all().delete() - - self.stdout.write( - self.style.SUCCESS( - f'Successfully deleted all quiz data:\n' - f'- {participant_answers_count} participant answers\n' - f'- {quiz_participants_count} quiz participants\n' - f'- {questions_count} questions\n' - f'- {quizzes_count} quizzes' - ) - ) - - except Exception as e: - self.stdout.write( - self.style.ERROR(f'Error occurred while clearing quiz data: {str(e)}') - ) - raise \ No newline at end of file diff --git a/apps/quiz/migrations/0001_initial.py b/apps/quiz/migrations/0001_initial.py deleted file mode 100644 index a496dba..0000000 --- a/apps/quiz/migrations/0001_initial.py +++ /dev/null @@ -1,220 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-22 10:48 - -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"), - ("account", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Quiz", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "title", - models.CharField( - help_text="Quiz Title", max_length=255, verbose_name="title" - ), - ), - ( - "description", - models.CharField( - blank=True, max_length=55, null=True, verbose_name="Description" - ), - ), - ("each_question_timing", models.PositiveIntegerField()), - ("status", models.BooleanField(default=True)), - ( - "lesson", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="quizzes", - to="course.courselesson", - verbose_name="lesson", - ), - ), - ], - options={ - "verbose_name": "Quiz", - "verbose_name_plural": "Quizzes", - "ordering": ("-id",), - }, - ), - migrations.CreateModel( - name="QuizRankUser", - fields=[], - options={ - "verbose_name": "Rank Quiz", - "verbose_name_plural": "Rank Quizzes", - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("account.user",), - ), - migrations.CreateModel( - name="QuizParticipant", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("started_at", models.DateTimeField(verbose_name="started at")), - ("ended_at", models.DateTimeField(verbose_name="ended at")), - ( - "total_timing", - models.PositiveIntegerField( - help_text="Seconds take to finish the quiz" - ), - ), - ("question_score", models.PositiveIntegerField()), - ("timing_score", models.PositiveIntegerField()), - ("total_score", models.PositiveIntegerField()), - ( - "quiz", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="participants", - to="quiz.quiz", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="uquizzes", - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - ], - options={ - "verbose_name": "Participant", - "verbose_name_plural": "Participants", - "ordering": ("-id",), - }, - ), - migrations.CreateModel( - name="Question", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("question", models.CharField(max_length=255)), - ("option1", models.CharField(max_length=255, verbose_name="option 1")), - ("option2", models.CharField(max_length=255, verbose_name="option 2")), - ("option3", models.CharField(max_length=255, verbose_name="option 3")), - ("option4", models.CharField(max_length=255, verbose_name="option 4")), - ( - "correct_answer", - models.PositiveSmallIntegerField( - choices=[ - (1, "Option 1"), - (2, "Option 2"), - (3, "Option 3"), - (4, "Option 4"), - ] - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="created at"), - ), - ("priority", models.IntegerField(blank=True, null=True)), - ( - "quiz", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="questions", - to="quiz.quiz", - verbose_name="quiz", - ), - ), - ], - options={ - "verbose_name": "Question", - "verbose_name_plural": "Questions", - "ordering": ("-priority", "-id"), - }, - ), - migrations.CreateModel( - name="ParticipantAnswer", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "option_num", - models.PositiveSmallIntegerField( - choices=[ - (1, "Option 1"), - (2, "Option 2"), - (3, "Option 3"), - (4, "Option 4"), - ], - verbose_name="selected option", - ), - ), - ("at_time", models.DateTimeField()), - ( - "answer_timing", - models.PositiveSmallIntegerField( - default=0, verbose_name="seconds take to answer" - ), - ), - ( - "participant", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="answers", - to="quiz.quizparticipant", - ), - ), - ( - "question", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="quiz.question" - ), - ), - ], - options={ - "verbose_name": "User Quiz Answer", - "verbose_name_plural": "User Quiz Answers", - "ordering": ("-id",), - }, - ), - ] diff --git a/apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py b/apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py deleted file mode 100644 index 173ff55..0000000 --- a/apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py +++ /dev/null @@ -1,147 +0,0 @@ -# Generated by Django 5.2.12 on 2026-05-03 14:09 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0004_alter_lessoncompletion_options_and_more'), - ('quiz', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='participantanswer', - name='answer_timing', - field=models.PositiveSmallIntegerField(default=0, verbose_name='Seconds Take to Answer'), - ), - migrations.AlterField( - model_name='participantanswer', - name='at_time', - field=models.DateTimeField(verbose_name='At Time'), - ), - migrations.AlterField( - model_name='participantanswer', - name='option_num', - field=models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='Selected Option'), - ), - migrations.AlterField( - model_name='participantanswer', - name='participant', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='quiz.quizparticipant', verbose_name='Participant'), - ), - migrations.AlterField( - model_name='participantanswer', - name='question', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question', verbose_name='Question'), - ), - migrations.AlterField( - model_name='question', - name='correct_answer', - field=models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='Correct Answer'), - ), - migrations.AlterField( - model_name='question', - name='created_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Created At'), - ), - migrations.AlterField( - model_name='question', - name='option1', - field=models.CharField(max_length=255, verbose_name='Option 1'), - ), - migrations.AlterField( - model_name='question', - name='option2', - field=models.CharField(max_length=255, verbose_name='Option 2'), - ), - migrations.AlterField( - model_name='question', - name='option3', - field=models.CharField(max_length=255, verbose_name='Option 3'), - ), - migrations.AlterField( - model_name='question', - name='option4', - field=models.CharField(max_length=255, verbose_name='Option 4'), - ), - migrations.AlterField( - model_name='question', - name='priority', - field=models.IntegerField(blank=True, null=True, verbose_name='Priority'), - ), - migrations.AlterField( - model_name='question', - name='question', - field=models.CharField(max_length=255, verbose_name='Question'), - ), - migrations.AlterField( - model_name='question', - name='quiz', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.quiz', verbose_name='Quiz'), - ), - migrations.AlterField( - model_name='quiz', - name='each_question_timing', - field=models.PositiveIntegerField(verbose_name='Each Question Timing'), - ), - migrations.AlterField( - model_name='quiz', - name='lesson', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.courselesson', verbose_name='Lesson'), - ), - migrations.AlterField( - model_name='quiz', - name='status', - field=models.BooleanField(default=True, verbose_name='Status'), - ), - migrations.AlterField( - model_name='quiz', - name='title', - field=models.CharField(help_text='Quiz Title', max_length=255, verbose_name='Title'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='ended_at', - field=models.DateTimeField(verbose_name='Ended At'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='question_score', - field=models.PositiveIntegerField(verbose_name='Question Score'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='quiz', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='quiz.quiz', verbose_name='Quiz'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='started_at', - field=models.DateTimeField(verbose_name='Started At'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='timing_score', - field=models.PositiveIntegerField(verbose_name='Timing Score'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='total_score', - field=models.PositiveIntegerField(verbose_name='Total Score'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='total_timing', - field=models.PositiveIntegerField(help_text='Seconds take to finish the quiz', verbose_name='Total Timing'), - ), - migrations.AlterField( - model_name='quizparticipant', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uquizzes', to=settings.AUTH_USER_MODEL, verbose_name='User'), - ), - ] diff --git a/apps/quiz/migrations/0003_quiz_course.py b/apps/quiz/migrations/0003_quiz_course.py deleted file mode 100644 index ba5c3ae..0000000 --- a/apps/quiz/migrations/0003_quiz_course.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.12 on 2026-05-16 15:01 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0006_alter_course_professor_alter_course_video_file_and_more'), - ('quiz', '0002_alter_participantanswer_answer_timing_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='quiz', - name='course', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.course', verbose_name='Course'), - ), - ] diff --git a/apps/quiz/migrations/__init__.py b/apps/quiz/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/quiz/models/__init__.py b/apps/quiz/models/__init__.py deleted file mode 100644 index 16a10f1..0000000 --- a/apps/quiz/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .quiz import * -from .participant import * \ No newline at end of file diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py deleted file mode 100644 index f2a46da..0000000 --- a/apps/quiz/models/participant.py +++ /dev/null @@ -1,68 +0,0 @@ -from django.db import models -from django.db.models import F, Window -from django.db.models.functions import Rank -from django.utils.translation import gettext_lazy as _ - -from apps.account.models import User - - -class QuizParticipant(models.Model): - quiz = models.ForeignKey('quiz.Quiz', on_delete=models.CASCADE, related_name='participants', verbose_name=_('Quiz')) - user = models.ForeignKey('account.User', on_delete=models.CASCADE, verbose_name=_('User'), related_name='uquizzes') - started_at = models.DateTimeField(verbose_name=_('Started At')) - ended_at = models.DateTimeField(verbose_name=_('Ended At')) - total_timing = models.PositiveIntegerField(verbose_name=_('Total Timing'), help_text=_('Seconds take to finish the quiz')) - - question_score = models.PositiveIntegerField(verbose_name=_('Question Score')) - timing_score = models.PositiveIntegerField(verbose_name=_('Timing Score')) - total_score = models.PositiveIntegerField(verbose_name=_('Total Score')) - - class Meta: - verbose_name = _("Participant") - verbose_name_plural = _("Participants") - ordering = ("-id",) - - def __str__(self): - return f"Participant: {self.id}, ParticipantName: {self.user}, Quiz: {self.quiz.id}" - - def __repr__(self): - return f"Participant(id={self.id})" - - - @staticmethod - def get_user_ranks(quiz_id): - return QuizParticipant.objects.filter(quiz_id=quiz_id).annotate( - rank=Window( - expression=Rank(), - order_by=F('total_score').desc() - ) - ) - - -class ParticipantAnswer(models.Model): - CHOICES = [ - (1, _('Option 1')), - (2, _('Option 2')), - (3, _('Option 3')), - (4, _('Option 4')), - ] - - participant = models.ForeignKey(QuizParticipant, on_delete=models.CASCADE, related_name='answers', verbose_name=_('Participant')) - question = models.ForeignKey("quiz.Question", on_delete=models.CASCADE, verbose_name=_('Question')) - option_num = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name=_('Selected Option')) - at_time = models.DateTimeField(verbose_name=_('At Time')) - answer_timing = models.PositiveSmallIntegerField(default=0, verbose_name=_('Seconds Take to Answer')) - - - class Meta: - verbose_name = _("User Quiz Answer") - verbose_name_plural = _("User Quiz Answers") - ordering = ("-id",) - - def __str__(self): - return f"Participant Answer: {self.id}" - - def __repr__(self): - return f"ParticipantAnswer(id={self.id})" - - diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py deleted file mode 100644 index 37a9564..0000000 --- a/apps/quiz/models/quiz.py +++ /dev/null @@ -1,71 +0,0 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ -from apps.account.models import User - - - -class Quiz(models.Model): - lesson = models.ForeignKey("course.CourseLesson", verbose_name=_('Lesson'), related_name='quizzes', on_delete=models.CASCADE) - course = models.ForeignKey("course.Course", verbose_name=_('Course'), related_name='quizzes', on_delete=models.CASCADE , null=True, blank=True) - - title = models.CharField(max_length=255, verbose_name=_('Title'), help_text=_("Quiz Title")) - description = models.CharField(max_length=55, blank=True, null=True, verbose_name=_("Description")) - each_question_timing = models.PositiveIntegerField(verbose_name=_("Each Question Timing")) - status = models.BooleanField(default=True, verbose_name=_("Status")) - - - class Meta: - verbose_name = _("Quiz") - verbose_name_plural = _("Quizzes") - ordering = ("-id",) - - def __str__(self): - return f"Quiz: {self.id}" - - def __repr__(self): - return f"Quiz(id={self.id})" - - def save(self, *args, **kwargs): - # Automatically set the course based on the selected lesson - if self.lesson_id and hasattr(self.lesson, 'course'): - self.course = self.lesson.course - super().save(*args, **kwargs) - - -class Question(models.Model): - CHOICES = [ - (1, _('Option 1')), - (2, _('Option 2')), - (3, _('Option 3')), - (4, _('Option 4')), - ] - - quiz = models.ForeignKey(Quiz, verbose_name=_('Quiz'), on_delete=models.CASCADE, related_name='questions') - question = models.CharField(max_length=255, verbose_name=_('Question')) - option1 = models.CharField(max_length=255, verbose_name=_('Option 1')) - option2 = models.CharField(max_length=255, verbose_name=_('Option 2')) - option3 = models.CharField(max_length=255, verbose_name=_('Option 3')) - option4 = models.CharField(max_length=255, verbose_name=_('Option 4')) - correct_answer = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name=_('Correct Answer')) - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) - priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority')) - - - class Meta: - verbose_name = _("Question") - verbose_name_plural = _("Questions") - ordering = ("-priority", "-id",) - - def __str__(self): - return self.question - - def __repr__(self): - return f"Question(id={self.id})" - - -class QuizRankUser(User): - class Meta: - proxy = True - verbose_name = _('Rank Quiz') - verbose_name_plural = _('Rank Quizzes') - diff --git a/apps/quiz/serializers/__init__.py b/apps/quiz/serializers/__init__.py deleted file mode 100644 index 16a10f1..0000000 --- a/apps/quiz/serializers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .quiz import * -from .participant import * \ No newline at end of file diff --git a/apps/quiz/serializers/participant.py b/apps/quiz/serializers/participant.py deleted file mode 100644 index 2ec1280..0000000 --- a/apps/quiz/serializers/participant.py +++ /dev/null @@ -1,45 +0,0 @@ -from rest_framework import serializers - -from apps.quiz.models import QuizParticipant, ParticipantAnswer - - -class ParticipantAnswerSerializer(serializers.ModelSerializer): - class Meta: - model = ParticipantAnswer - fields = ['question', 'option_num', 'at_time', 'answer_timing'] - - - -class QuizParticipantSerializer(serializers.ModelSerializer): - user = serializers.HiddenField(default=serializers.CurrentUserDefault()) - answers = ParticipantAnswerSerializer(many=True) - - def validate_quiz(self, obj): - if QuizParticipant.objects.filter(quiz=obj, user=self.context['request'].user).exists(): - raise serializers.ValidationError('you have already participated in the quiz') - - return obj - - class Meta: - model = QuizParticipant - fields = [ - 'quiz', 'user', 'started_at', 'ended_at', 'total_timing', - 'question_score', 'timing_score', 'total_score', - 'answers', - ] - - def create(self, validated_data): - answers = validated_data.pop('answers', []) - obj = super().create(validated_data) - answers_objs = [] - for ans in answers: - answers_objs.append( - ParticipantAnswer( - participant=obj, - **ans, - ) - ) - - ParticipantAnswer.objects.bulk_create(answers_objs) - - return obj diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py deleted file mode 100644 index 6156e64..0000000 --- a/apps/quiz/serializers/quiz.py +++ /dev/null @@ -1,100 +0,0 @@ -from rest_framework import serializers - -from apps.quiz.models import Question, Quiz, QuizParticipant -from apps.course.models import Participant - - - - - - - -class QuizListSerializer(serializers.ModelSerializer): - is_complated = serializers.SerializerMethodField() - permission = serializers.SerializerMethodField() - - class Meta: - model = Quiz - fields = ['id', 'title', 'description', 'permission', 'each_question_timing', 'is_complated'] - - def get_permission(self, obj): - request = self.context.get('request') - if not request or not request.user.is_authenticated: - return False - # Check if the user has participated in this quiz - user = request.user - - # obj.lesson is now CourseLesson directly - course_lesson = obj.lesson - if not course_lesson: - return False - - course = course_lesson.course - - if not self._is_participant(user, course): - return False - - participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() - return not participated - - - def _is_participant(self, student, course): - """Helper method to check if a student is a participant in the given course.""" - return Participant.objects.filter(student=student, course=course).exists() - - def get_is_complated(self, obj): - request = self.context.get('request') - if not request or not request.user.is_authenticated: - return False - user = request.user - return QuizParticipant.objects.filter(user=user, quiz=obj).exists() - - - - - -class QuestionSerializer(serializers.ModelSerializer): - options = serializers.SerializerMethodField() - - def get_options(self, obj) -> list: - return [ - { - 'id': i, - 'title': getattr(obj, f"option{i}") - } for i in range(1, 5) - ] - - class Meta: - model = Question - fields = ['id', 'question', 'options', 'correct_answer'] - - -class QuizSerializer(serializers.ModelSerializer): - lesson = serializers.PrimaryKeyRelatedField(read_only=True) - questions = QuestionSerializer(many=True) - permission = serializers.SerializerMethodField() - - class Meta: - model = Quiz - fields = ['id', 'permission', 'lesson', 'title', 'description', 'each_question_timing', 'questions'] - - def get_permission(self, obj): - request = self.context.get('request') - if not request or not request.user.is_authenticated: - return False - # Check if the user has participated in this quiz - user = request.user - - # obj.lesson is now CourseLesson directly - course_lesson = obj.lesson - if not course_lesson: - return False - - course = course_lesson.course - - # Check if user is a participant in the course - if not Participant.objects.filter(student=user, course=course).exists(): - return False - - participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() - return participated diff --git a/apps/quiz/tests.py b/apps/quiz/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/quiz/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/quiz/urls.py b/apps/quiz/urls.py deleted file mode 100644 index 6ced27c..0000000 --- a/apps/quiz/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - # path('prizes/', PrizeListAPIView.as_view()), - # path('ranked-list/', RankedListAPIView.as_view()), - # path('self-rank/', SelfRankAPIView.as_view()), - # path('my-quizzes/', UserQuizScores.as_view()), - path('submit-quiz/', views.QuizParticipantCreateAPIView.as_view()), - path('/', views.QuizDetailAPIView.as_view()), - -] diff --git a/apps/quiz/views.py b/apps/quiz/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/apps/quiz/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/apps/quiz/views/__init__.py b/apps/quiz/views/__init__.py deleted file mode 100644 index 16a10f1..0000000 --- a/apps/quiz/views/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .quiz import * -from .participant import * \ No newline at end of file diff --git a/apps/quiz/views/participant.py b/apps/quiz/views/participant.py deleted file mode 100644 index c4d89c4..0000000 --- a/apps/quiz/views/participant.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.db.models import Value -from rest_framework.generics import RetrieveAPIView, CreateAPIView -from rest_framework.permissions import IsAuthenticated -from rest_framework.authentication import TokenAuthentication - -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi - -from apps.quiz.serializers import QuizParticipantSerializer -from apps.quiz.doc import * - - - -class QuizParticipantCreateAPIView(CreateAPIView): - serializer_class = QuizParticipantSerializer - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - - - @swagger_auto_schema( - operation_description=doc_quiz_submit(), - tags=["Imam-Javad - Quiz"], - ) - def post(self, request, *args, **kwargs): - return super().post(request, *args, **kwargs) \ No newline at end of file diff --git a/apps/quiz/views/quiz.py b/apps/quiz/views/quiz.py deleted file mode 100644 index 49a6fa3..0000000 --- a/apps/quiz/views/quiz.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db.models import Value -from rest_framework.generics import RetrieveAPIView -from rest_framework.permissions import IsAuthenticated -from rest_framework.authentication import TokenAuthentication - -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from apps.quiz.models import Quiz -from apps.quiz.serializers.quiz import QuizSerializer -from apps.quiz.doc import * - - - -class QuizDetailAPIView(RetrieveAPIView): - serializer_class = QuizSerializer - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - - - @swagger_auto_schema( - operation_description=doc_quiz_detail(), - tags=["Imam-Javad - Quiz"], - ) - def get(self, request, *args, **kwargs): - return super().get(request, *args, **kwargs) - - def get_object(self): - return Quiz.objects.filter( - id=self.kwargs['quiz_id'], - ).annotate( - lesson__has_quiz=Value(True) - ).select_related('lesson').first() diff --git a/apps/transaction/__init__.py b/apps/transaction/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transaction/admin.py b/apps/transaction/admin.py deleted file mode 100644 index 80107fd..0000000 --- a/apps/transaction/admin.py +++ /dev/null @@ -1,165 +0,0 @@ -from django.contrib import admin -from django.utils.translation import gettext_lazy as _ -from django.utils.html import format_html -from django.contrib import messages - -from unfold.admin import ModelAdmin, StackedInline, TabularInline -from unfold.decorators import display - -from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt -from apps.course.models import Participant - -from utils.admin import project_admin_site - -class ParticipantInfoInline(StackedInline): - model = ParticipantInfo - extra = 1 - fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate'] - # readonly_fields = ['email', 'phone_number'] - classes = ['collapse'] - tab = True - show_change_link = True - - -class TransactionReceiptInline(TabularInline): - model = TransactionReceipt - extra = 0 - fields = ['file', 'description', 'uploaded_at'] - readonly_fields = ['uploaded_at'] - classes = ['collapse'] - tab = True - show_change_link = True - verbose_name = _('Payment Receipt') - verbose_name_plural = _('Payment Receipts') - - -@admin.register(TransactionParticipant) -class TransactionParticipantAdmin(ModelAdmin): - list_display = ('user', 'course', 'payment_status', 'price_display', 'participant_status', 'receipts_count', 'created_at', 'updated_at') - list_filter = ('status', 'course', 'created_at') - search_fields = ('user__email', 'course__title') - readonly_fields = ['created_at', 'updated_at'] - inlines = [ParticipantInfoInline, TransactionReceiptInline] - autocomplete_fields = ['user',] - show_change_link = True - ordering = ('-created_at',) - - fieldsets = ( - (None, { - 'fields': ('user', 'course', 'status', 'price') - }), - (_('Timestamps'), { - 'fields': ('created_at', 'updated_at'), - 'classes': ('collapse',) - }), - ) - - @display(description=_("Payment Status"), ordering="status") - def payment_status(self, obj): - if obj.status == 'success': - return format_html('{}', _("Paid")) - elif obj.status == 'failed': - return format_html('{}', _("Failed")) - elif obj.status == 'waiting_approval': - return format_html('{}', _("Waiting Approval")) - return format_html('{}', _("Pending")) - - @display(description=_("Receipts Count")) - def receipts_count(self, obj): - """Display count of uploaded receipts""" - count = obj.receipts.count() - if count > 0: - return format_html('{} {}', count, _("receipts")) - return format_html('{}', _("No receipts")) - - @display(description=_("Price"), ordering="price") - def price_display(self, obj): - return format_html('${}', obj.price) - - @display(description=_("Course Participant Status")) - def participant_status(self, obj): - """نمایش وضعیت شرکت‌کننده در دوره""" - if obj.status == TransactionParticipant.TransactionStatus.SUCCESS: - participant_exists = Participant.objects.filter( - student=obj.user, - course=obj.course - ).exists() - if participant_exists: - return format_html('✓ {}', _("Enrolled")) - else: - return format_html('⚠ {}', _("Not Enrolled")) - else: - return format_html('- {}', _("Not Applicable")) - - def save_model(self, request, obj, form, change): - """Override save_model to show messages when participant is created""" - if change: - # Store the old status before saving - old_obj = TransactionParticipant.objects.get(pk=obj.pk) - old_status = old_obj.status - - # Save the object - super().save_model(request, obj, form, change) - - # Check if status changed to SUCCESS - if (old_status != TransactionParticipant.TransactionStatus.SUCCESS and - obj.status == TransactionParticipant.TransactionStatus.SUCCESS): - - participant_exists = Participant.objects.filter( - student=obj.user, - course=obj.course - ).exists() - - if participant_exists: - messages.success( - request, - _("Transaction status updated to SUCCESS. User {user_email} is now enrolled in course '{course_title}'.").format( - user_email=obj.user.email, - course_title=obj.course.title - ) - ) - else: - messages.warning( - request, - _("Transaction status updated to SUCCESS, but there was an issue enrolling user {user_email} in course '{course_title}'. Please check the logs.").format( - user_email=obj.user.email, - course_title=obj.course.title - ) - ) - else: - super().save_model(request, obj, form, change) - - def get_queryset(self, request): - # Filter out deleted transactions - return super().get_queryset(request).filter(is_deleted=False) - -project_admin_site.register(TransactionParticipant, TransactionParticipantAdmin) - - -@admin.register(TransactionReceipt) -class TransactionReceiptAdmin(ModelAdmin): - list_display = ('transaction', 'file', 'uploaded_at', 'description_preview') - list_filter = ('uploaded_at', 'transaction__status') - search_fields = ('transaction__user__email', 'transaction__course__title', 'description') - readonly_fields = ['uploaded_at'] - autocomplete_fields = ['transaction'] - ordering = ('-uploaded_at',) - - fieldsets = ( - (None, { - 'fields': ('transaction', 'file', 'description') - }), - (_('Timestamps'), { - 'fields': ('uploaded_at',), - 'classes': ('collapse',) - }), - ) - - @display(description=_("Description")) - def description_preview(self, obj): - """Display truncated description""" - if obj.description: - return obj.description[:50] + '...' if len(obj.description) > 50 else obj.description - return '-' - -project_admin_site.register(TransactionReceipt, TransactionReceiptAdmin) diff --git a/apps/transaction/apps.py b/apps/transaction/apps.py deleted file mode 100644 index d10da14..0000000 --- a/apps/transaction/apps.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.apps import AppConfig - - -class TransactionConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.transaction' - - def ready(self): - import apps.transaction.signals diff --git a/apps/transaction/doc.py b/apps/transaction/doc.py deleted file mode 100644 index 1eacff9..0000000 --- a/apps/transaction/doc.py +++ /dev/null @@ -1,711 +0,0 @@ -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from rest_framework import status - -def doc_upload_transaction_receipts(): - return """ -# 🐈 Scenario -🛠️ آپلود رسید پرداخت برای تراکنش - -این API برای آپلود یک یا چند رسید پرداخت برای یک تراکنش استفاده می‌شود. -پس از آپلود موفقیت‌آمیز، وضعیت تراکنش به 'waiting_approval' (در انتظار تایید) تغییر می‌کند. - ---- - -## 🚀 روند آپلود (دو مرحله‌ای) - -### مرحله 1️⃣: آپلود فایل به سرور موقت -ابتدا باید فایل‌های خود را به endpoint زیر آپلود کنید: - -``` -POST /upload-tmp-media/ -Content-Type: multipart/form-data - -Body: -- file: [فایل رسید] -``` - -**پاسخ:** -```json -{ - "url": "/static/tmp/xyz123-receipt.jpg", - "name": "receipt.jpg", - "size": "1024000", - "mime_type": "image/jpeg" -} -``` - -### مرحله 2️⃣: ثبت URL فایل‌ها در تراکنش -سپس URL های دریافتی را به این endpoint ارسال کنید: - -``` -POST /api/transactions//receipts/upload/ -Content-Type: application/json -``` - ---- - -## 🚀 درخواست API (مرحله 2) - -### URL: -``` -POST /api/transactions//receipts/upload/ -``` - -### پارامترهای URL: -| کلید | نوع داده | توضیحات | -|------------------|-----------|----------------------------------------------------------| -| `transaction_id` | Integer | شناسه تراکنش که می‌خواهید رسید برای آن ثبت کنید | - -### پارامترهای درخواست (JSON Body): -| کلید | نوع داده | الزامی | توضیحات | -|---------------|-----------|--------|----------------------------------------------------------| -| `files` | String[] | بله | لیست URL های فایل‌های آپلود شده از مرحله 1 (حداکثر 10 فایل) | -| `description` | String | خیر | توضیحات اختیاری درباره رسیدها | - ---- - -## 💡 نکات مهم: -1. **روند دو مرحله‌ای**: - - **مرحله 1**: ابتدا فایل‌ها را به `/upload-tmp-media/` آپلود کنید - - **مرحله 2**: سپس URL های دریافتی را به این API ارسال کنید - -2. **محدودیت فایل‌ها**: - - حداکثر 10 فایل می‌توانید در هر درخواست ثبت کنید - -3. **وضعیت تراکنش**: - - فقط می‌توانید برای تراکنش‌هایی با وضعیت 'pending' یا 'waiting_approval' رسید آپلود کنید - - پس از ثبت موفقیت‌آمیز، وضعیت تراکنش به 'waiting_approval' تغییر می‌کند - -4. **احراز هویت**: - - باید توکن احراز هویت را در هدر درخواست ارسال کنید - - فقط می‌توانید برای تراکنش‌های خودتان رسید آپلود کنید - ---- - -## 📊 پاسخ‌ها - -| کد وضعیت | توضیحات | -|---------------|-----------------------------------------------------------| -| `201` | موفقیت‌آمیز - رسیدها با موفقیت ثبت شدند | -| `400` | داده‌های نامعتبر یا تراکنش قادر به دریافت رسید نیست | -| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید | -| `404` | تراکنش یافت نشد | - ---- - -## 📄 نمونه درخواست کامل (JSON): - -```json -{ - "files": [ - "/static/tmp/xyz123-receipt1.jpg", - "/static/tmp/abc456-receipt2.jpg" - ], - "description": "Payment receipt for Python course" -} -``` - ---- - -## 📄 نمونه پاسخ موفقیت‌آمیز - -```json -{ - "success": true, - "message": "Receipts uploaded successfully", - "transaction_status": "waiting_approval", - "receipts": [ - { - "id": 1, - "file": "http://example.com/media/receipts/1/receipt1.jpg", - "description": "Payment receipt for course enrollment", - "uploaded_at": "2025-12-03T10:30:00Z" - }, - { - "id": 2, - "file": "http://example.com/media/receipts/1/receipt2.jpg", - "description": "Payment receipt for course enrollment", - "uploaded_at": "2025-12-03T10:30:05Z" - } - ] -} -``` - ---- - -## 📄 نمونه درخواست کامل (cURL): - -### مرحله 1 - آپلود فایل: -```bash -curl -X POST \\ - 'http://your-api.com/upload-tmp-media/' \\ - -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ - -F 'file=@/path/to/receipt1.jpg' -``` - -### مرحله 2 - ثبت رسید: -```bash -curl -X POST \\ - 'http://your-api.com/api/transactions/123/receipts/upload/' \\ - -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "files": ["/static/tmp/xyz123-receipt1.jpg"], - "description": "Payment receipt for Python course" - }' -``` - ---- - -## 📄 نمونه پاسخ خطا (403 - عدم دسترسی): - -```json -{ - "message": "You don't have permission to upload receipts for this transaction" -} -``` - ---- - -## 📄 نمونه پاسخ خطا (400 - وضعیت نامعتبر): - -```json -{ - "message": "Cannot upload receipts for transaction with status 'success'" -} -``` -""" - - -def doc_list_transaction_receipts(): - return """ -# 🐈 Scenario -🛠️ لیست رسیدهای پرداخت یک تراکنش - -این API برای دریافت لیست تمام رسیدهای آپلود شده برای یک تراکنش خاص استفاده می‌شود. - ---- - -## 🚀 درخواست API - -### URL: -``` -GET /api/transactions//receipts/ -``` - -### پارامترهای URL: -| کلید | نوع داده | توضیحات | -|------------------|-----------|----------------------------------------------------------| -| `transaction_id` | Integer | شناسه تراکنش که می‌خواهید رسیدهای آن را مشاهده کنید | - ---- - -## 💡 نکات مهم: -1. **احراز هویت**: - - باید توکن احراز هویت را در هدر درخواست ارسال کنید - - فقط می‌توانید رسیدهای تراکنش‌های خودتان را مشاهده کنید - -2. **مرتب‌سازی**: - - رسیدها بر اساس تاریخ آپلود (جدیدترین اول) مرتب می‌شوند - ---- - -## 📊 پاسخ‌ها - -| کد وضعیت | توضیحات | -|---------------|-----------------------------------------------------------| -| `200` | موفقیت‌آمیز - لیست رسیدها بازگردانده شد | -| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید | -| `404` | تراکنش یافت نشد | - ---- - -## 📄 نمونه پاسخ موفقیت‌آمیز - -```json -[ - { - "id": 1, - "file": "http://example.com/media/receipts/1/receipt1.jpg", - "description": "Payment receipt for course enrollment", - "uploaded_at": "2025-12-03T10:30:00Z" - }, - { - "id": 2, - "file": "http://example.com/media/receipts/1/receipt2.jpg", - "description": "Second payment receipt", - "uploaded_at": "2025-12-03T10:25:00Z" - } -] -``` - ---- - -## 📄 توضیحات مقادیر پاسخ - -| کلید | نوع داده | توضیحات | -|---------------|------------|----------------------------------------------------------| -| `id` | Integer | شناسه یکتای رسید | -| `file` | String | URL کامل فایل رسید آپلود شده | -| `description` | String | توضیحات اختیاری درباره رسید (ممکن است خالی باشد) | -| `uploaded_at` | DateTime | تاریخ و زمان آپلود رسید | - ---- - -## 📄 نمونه درخواست (cURL): - -```bash -curl -X GET \\ - 'http://your-api.com/api/transactions/123/receipts/' \\ - -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' -``` - ---- - -## 📄 نمونه پاسخ خطا (403 - عدم دسترسی): - -```json -{ - "message": "You don't have permission to view receipts for this transaction" -} -``` - ---- - -## 📄 نمونه پاسخ خطا (404 - تراکنش یافت نشد): - -```json -{ - "message": "Transaction not found" -} -``` -""" - - -def doc_transaction_list(): - return """ -# 🐈 Scenario -🛠️ لیست تراکنش‌های کاربر - -این API برای دریافت لیست تمام تراکنش‌های کاربر احراز هویت شده استفاده می‌شود. - ---- - -## 🚀 درخواست API - -### URL: -``` -GET /api/transactions/list/ -``` - ---- - -## 💡 نکات مهم: -1. **احراز هویت**: - - باید توکن احراز هویت را در هدر درخواست ارسال کنید - - فقط تراکنش‌های خودتان را مشاهده می‌کنید - -2. **فیلترینگ خودکار**: - - تراکنش‌های حذف شده (soft deleted) نمایش داده نمی‌شوند - -3. **وضعیت‌های تراکنش**: - - `pending`: در انتظار پرداخت - - `waiting_approval`: در انتظار تایید (رسید آپلود شده) - - `success`: پرداخت موفق و تایید شده - - `failed`: پرداخت ناموفق - ---- - -## 📊 پاسخ‌ها - -| کد وضعیت | توضیحات | -|---------------|-----------------------------------------------------------| -| `200` | موفقیت‌آمیز - لیست تراکنش‌ها بازگردانده شد | -| `401` | عدم احراز هویت | - ---- - -## 📄 توضیحات مقادیر پاسخ - -| کلید | نوع داده | توضیحات | -|---------------|------------|----------------------------------------------------------| -| `id` | Integer | شناسه یکتای تراکنش | -| `course` | Object | اطلاعات دوره مرتبط با تراکنش | -| `status` | String | وضعیت تراکنش (pending, waiting_approval, success, failed) | -| `price` | Decimal | مبلغ تراکنش | -| `created_at` | DateTime | تاریخ و زمان ایجاد تراکنش | -| `updated_at` | DateTime | تاریخ و زمان آخرین به‌روزرسانی تراکنش | - ---- - -## 📄 نمونه پاسخ موفقیت‌آمیز - -```json -[ - { - "id": 1, - "course": { - "id": 5, - "title": "Python Programming Basics", - "slug": "python-programming-basics", - "thumbnail": "http://example.com/media/courses/thumbnails/python.jpg", - "price": "99.00", - "final_price": "79.00" - }, - "status": "waiting_approval", - "price": "79.00", - "created_at": "2025-12-01T10:00:00Z", - "updated_at": "2025-12-03T10:30:00Z" - }, - { - "id": 2, - "course": { - "id": 8, - "title": "Django Web Development", - "slug": "django-web-development", - "thumbnail": "http://example.com/media/courses/thumbnails/django.jpg", - "price": "149.00", - "final_price": "149.00" - }, - "status": "success", - "price": "149.00", - "created_at": "2025-11-28T14:20:00Z", - "updated_at": "2025-11-29T09:15:00Z" - } -] -``` - ---- - -## 📄 نمونه درخواست (cURL): - -```bash -curl -X GET \\ - 'http://your-api.com/api/transactions/list/' \\ - -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' -``` -""" - - -def doc_create_transaction(): - return """ -# 🐈 Scenario -🛠️ ثبت‌نام در دوره و ایجاد تراکنش - -این API برای ثبت‌نام کاربر در یک دوره و ایجاد تراکنش استفاده می‌شود. - ---- - -## 🚀 درخواست API - -### URL: -``` -POST /api/transactions//join/ -``` - -### پارامترهای URL: -| کلید | نوع داده | توضیحات | -|---------|-----------|----------------------------------------------------------| -| `slug` | String | اسلاگ دوره‌ای که می‌خواهید در آن ثبت‌نام کنید | - -### پارامترهای درخواست (JSON Body): -| کلید | نوع داده | الزامی | توضیحات | -|---------------------|-----------|--------|----------------------------------------------------------| -| `participant_infos` | Array | بله | لیست اطلاعات شرکت‌کنندگان | - -### ساختار `participant_infos`: -| کلید | نوع داده | الزامی | توضیحات | -|---------------|-----------|--------|----------------------------------------------------------| -| `fullname` | String | بله | نام کامل شرکت‌کننده | -| `email` | String | بله | ایمیل شرکت‌کننده (برای دوره رایگان باید با ایمیل کاربر احراز هویت شده یکسان باشد) | -| `phone_number`| String | خیر | شماره تلفن شرکت‌کننده | -| `gender` | String | خیر | جنسیت شرکت‌کننده (male, female) | -| `birthdate` | Date | خیر | تاریخ تولد شرکت‌کننده (فرمت: YYYY-MM-DD) | - ---- - -## 💡 نکات مهم: -1. **دوره رایگان**: - - اگر دوره رایگان باشد و فقط یک شرکت‌کننده در لیست باشد و ایمیل او با کاربر احراز هویت شده یکسان باشد، تراکنش به صورت خودکار تایید می‌شود (status = 'success') - - کاربر به صورت خودکار به عنوان دانشجو در دوره ثبت می‌شود - -2. **دوره پولی**: - - تراکنش با وضعیت 'pending' ایجاد می‌شود - - سیستم بر اساس موقعیت جغرافیایی کاربر، روش پرداخت مناسب را تعیین می‌کند - -3. **روش پرداخت (Payment Method)**: - - **Payment_Gateway**: برای کاربران غیر روسی - پرداخت از طریق درگاه پرداخت آنلاین - - **Receipt**: برای کاربران روسی - آپلود رسید پرداخت از طریق واتس‌اپ - -4. **تشخیص موقعیت جغرافیایی**: - - ابتدا از هدر Cloudflare (`CF-IPCountry`) استفاده می‌شود - - در صورت عدم وجود، از پایگاه داده GeoIP محلی استفاده می‌شود - - کاربران روسی روش پرداخت Receipt دریافت می‌کنند - - کاربران سایر کشورها روش پرداخت Payment_Gateway دریافت می‌کنند - -5. **احراز هویت**: - - باید توکن احراز هویت را در هدر درخواست ارسال کنید - ---- - -## 📊 پاسخ‌ها - -| کد وضعیت | توضیحات | -|---------------|-----------------------------------------------------------| -| `201` | موفقیت‌آمیز - تراکنش ایجاد شد | -| `400` | داده‌های نامعتبر | -| `404` | دوره یافت نشد | - -### ساختار پاسخ: -| کلید | نوع داده | توضیحات | -|---------------------|-----------|----------------------------------------------------------| -| `message` | String | پیام موفقیت‌آمیز | -| `transaction_id` | Integer | شناسه تراکنش ایجاد شده | -| `payment_method` | String | روش پرداخت (Payment_Gateway یا Receipt) | -| `payment_link` | String | لینک پرداخت (فقط برای Payment_Gateway) | -| `participant_infos` | Array | لیست اطلاعات شرکت‌کنندگان | - ---- - -## 💳 روش‌های پرداخت: - -### Payment_Gateway (درگاه پرداخت): -- **کاربران**: غیر روسی -- **اقدام کاربر**: کلیک روی `payment_link` و پرداخت آنلاین -- **فرآیند**: پرداخت مستقیم از طریق درگاه پرداخت - -### Receipt (رسید پرداخت): -- **کاربران**: روسی -- **اقدام کاربر**: آپلود رسید پرداخت از طریق واتس‌اپ -- **فرآیند**: آپلود رسید → بررسی توسط ادمین → تایید پرداخت - ---- - -## 📄 نمونه درخواست (JSON Body): - -```json -{ - "participant_infos": [ - { - "fullname": "علی رضایی", - "email": "ali@example.com", - "phone_number": "+989123456789", - "gender": "male", - "birthdate": "1995-05-15" - } - ] -} -``` - ---- - -## 📄 نمونه پاسخ (دوره رایگان): - -```json -{ - "message": "Transaction Participant created successfully.", - "transaction_id": 123, - "payment_method": Free, - "payment_link": null, - "participant_infos": [ - { - "fullname": "علی رضایی", - "email": "ali@example.com", - "phone_number": "+989123456789", - "gender": "male", - "birthdate": "1995-05-15" - } - ] -} -``` - ---- - -## 📄 نمونه پاسخ (دوره پولی - Payment_Gateway): - -```json -{ - "message": "Transaction Participant created successfully.", - "transaction_id": 374, - "payment_method": "Payment_Gateway", - "payment_link": "https://russia-payment.com/pay/374", - "participant_infos": [ - { - "fullname": "John Doe", - "email": "john@example.com", - "phone_number": "+1234567890", - "gender": "male", - "birthdate": "1990-01-01" - } - ] -} -``` - ---- - -## 📄 نمونه پاسخ (دوره پولی - Receipt): - -```json -{ - "message": "Transaction Participant created successfully.", - "transaction_id": 375, - "payment_method": "receipt", - "payment_link": null, - "participant_infos": [ - { - "fullname": "Иван Иванов", - "email": "ivan@example.ru", - "phone_number": "+71234567890", - "gender": "male", - "birthdate": "1992-05-15" - } - ] -} -``` - -**نکته**: برای روش پرداخت Receipt، کاربر باید رسید پرداخت خود را از طریق واتس‌اپ آپلود کند. - ---- - -## 📄 نمونه درخواست (cURL): - -```bash -curl -X POST \\ - 'http://your-api.com/api/transactions/python-programming-basics/join/' \\ - -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "participant_infos": [ - { - "fullname": "علی رضایی", - "email": "ali@example.com", - "phone_number": "+989123456789", - "gender": "male", - "birthdate": "1995-05-15" - } - ] - }' -``` -""" - - -hadis_list_swagger = swagger_auto_schema( - operation_description=""" - Retrieve a paginated list of Hadis (traditions) for a specific category. - - **Key Features:** - - Returns hadis entries filtered by category ID - - Supports pagination for large datasets - - Translations are automatically provided based on the Accept-Language header - - Each hadis includes its category information, title, narrator, Arabic text, and translation - - **Usage:** - - Use this endpoint to browse hadis within a specific category - - The response includes pagination links (next/previous) for navigation - - Set the Accept-Language header to get translations in your preferred language (en, fa, ar, ur) - - Only active (status=True) hadis are returned - - **Response Structure:** - - `count`: Total number of hadis in the category - - `next`: URL for the next page (null if on last page) - - `previous`: URL for the previous page (null if on first page) - - `results`: Array of hadis objects with full details - """, - operation_summary="List Hadis by Category", - tags=['Hadis'], - manual_parameters=[ - openapi.Parameter( - 'category_slug', - openapi.IN_PATH, - description="Unique identifier of the Hadis category. Must be a valid category ID that exists in the system.", - type=openapi.TYPE_STRING, - required=True, - example='-330' - ), - openapi.Parameter( - 'page', - openapi.IN_QUERY, - description="Page number for pagination. Starts from 1. If not provided, returns the first page.", - type=openapi.TYPE_INTEGER, - required=False, - example=1 - ), - openapi.Parameter( - 'Accept-Language', - openapi.IN_HEADER, - description="Language code for translations. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu). Defaults to 'en' if not specified.", - type=openapi.TYPE_STRING, - required=False, - default='en', - enum=['en', 'fa', 'ar', 'ur'] - ) - ], - responses={ - status.HTTP_200_OK: openapi.Response( - description="Successfully retrieved paginated list of hadis for the specified category", - examples={ - "application/json": { - "count": 150, - "next": "http://example.com/api/hadis/category/1/?page=2", - "previous": None, - "results": [ - { - "id": 1, - "number": 1, - "title": "The Opening", - "title_narrator": "From Abu Hurairah", - "text": "إنما الأعمال بالنيات وإنما لكل امرئ ما نوى", - "translation": "Actions are but by intention, and every man shall have only what he intended", - "category": { - "id": 1, - "title": "Book of Faith", - "slug": "book-of-faith", - "source_type": "hadith", - "sect_type": "sunni" - }, - "status": { - "id": 130, - "title": "Прерванный", - "color": "orange" - }, - "share_link": "http://example.com/hadis/1" - }, - { - "id": 2, - "number": 2, - "title": "The Second Hadith", - "title_narrator": "From Umar ibn al-Khattab", - "text": "بينما نحن عند رسول الله صلى الله عليه وسلم ذات يوم", - "translation": "While we were sitting with the Messenger of Allah (peace be upon him) one day", - "category": { - "id": 1, - "title": "Book of Faith", - "slug": "book-of-faith", - "source_type": "hadith", - "sect_type": "sunni" - }, - "status": { - "id": 130, - "title": "Прерванный", - "color": "orange" - }, - "share_link": "http://example.com/hadis/2" - } - ] - } - } - ), - status.HTTP_404_NOT_FOUND: openapi.Response( - description="The specified category ID does not exist or the category has no active hadis", - examples={ - "application/json": { - "detail": "Not found." - } - } - ), - status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( - description="Internal server error occurred while processing the request" - ) - } -) \ No newline at end of file diff --git a/apps/transaction/management/__init__.py b/apps/transaction/management/__init__.py deleted file mode 100644 index 11942f5..0000000 --- a/apps/transaction/management/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Management commands for transaction app \ No newline at end of file diff --git a/apps/transaction/management/commands/__init__.py b/apps/transaction/management/commands/__init__.py deleted file mode 100644 index 0b1b20d..0000000 --- a/apps/transaction/management/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Management commands \ No newline at end of file diff --git a/apps/transaction/management/commands/sync_successful_transactions.py b/apps/transaction/management/commands/sync_successful_transactions.py deleted file mode 100644 index cddae07..0000000 --- a/apps/transaction/management/commands/sync_successful_transactions.py +++ /dev/null @@ -1,201 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError -from django.db import transaction -from django.utils import timezone -from apps.transaction.models import TransactionParticipant -from apps.course.models import Participant -from apps.account.models import User - - -class Command(BaseCommand): - help = 'بررسی تراکنش‌های موفق و ایجاد شرکت‌کنندگان دوره در صورت عدم وجود' - - def add_arguments(self, parser): - parser.add_argument( - '--dry-run', - action='store_true', - help='فقط نمایش تراکنش‌هایی که نیاز به بروزرسانی دارند بدون اعمال تغییرات', - ) - parser.add_argument( - '--transaction-id', - type=int, - help='بررسی تراکنش خاص با ID مشخص', - ) - parser.add_argument( - '--user-email', - type=str, - help='بررسی تراکنش‌های کاربر خاص', - ) - parser.add_argument( - '--course-slug', - type=str, - help='بررسی تراکنش‌های دوره خاص', - ) - - def handle(self, *args, **options): - dry_run = options['dry_run'] - transaction_id = options.get('transaction_id') - user_email = options.get('user_email') - course_slug = options.get('course_slug') - - # ساخت queryset اولیه - queryset = TransactionParticipant.objects.filter( - status=TransactionParticipant.TransactionStatus.SUCCESS, - is_deleted=False - ).select_related('user', 'course') - - # اعمال فیلترهای اضافی - if transaction_id: - queryset = queryset.filter(id=transaction_id) - - if user_email: - try: - user = User.objects.get(email=user_email) - queryset = queryset.filter(user=user) - except User.DoesNotExist: - raise CommandError(f'کاربر با ایمیل {user_email} یافت نشد.') - - if course_slug: - queryset = queryset.filter(course__slug=course_slug) - - total_transactions = queryset.count() - - if total_transactions == 0: - self.stdout.write( - self.style.WARNING('هیچ تراکنش موفقی برای بررسی یافت نشد.') - ) - return - - self.stdout.write( - self.style.SUCCESS(f'تعداد {total_transactions} تراکنش موفق برای بررسی یافت شد.') - ) - - missing_participants = [] - existing_participants = [] - errors = [] - - # بررسی هر تراکنش - for trans in queryset: - try: - # بررسی وجود participant - participant_exists = Participant.objects.filter( - student=trans.user, - course=trans.course - ).exists() - - if not participant_exists: - missing_participants.append(trans) - self.stdout.write( - self.style.WARNING( - f'❌ تراکنش {trans.id}: کاربر {trans.user.email} در دوره "{trans.course.title}" ثبت‌نام نشده' - ) - ) - else: - existing_participants.append(trans) - self.stdout.write( - self.style.SUCCESS( - f'✅ تراکنش {trans.id}: کاربر {trans.user.email} در دوره "{trans.course.title}" قبلاً ثبت‌نام شده' - ) - ) - - except Exception as e: - errors.append((trans, str(e))) - self.stdout.write( - self.style.ERROR( - f'⚠️ خطا در بررسی تراکنش {trans.id}: {str(e)}' - ) - ) - - # نمایش خلاصه - self.stdout.write('\n' + '='*50) - self.stdout.write(f'📊 خلاصه نتایج:') - self.stdout.write(f' • کل تراکنش‌های بررسی شده: {total_transactions}') - self.stdout.write(f' • شرکت‌کنندگان موجود: {len(existing_participants)}') - self.stdout.write(f' • شرکت‌کنندگان ناموجود: {len(missing_participants)}') - self.stdout.write(f' • خطاها: {len(errors)}') - self.stdout.write('='*50 + '\n') - - if not missing_participants: - self.stdout.write( - self.style.SUCCESS('🎉 همه تراکنش‌های موفق دارای شرکت‌کننده مربوطه هستند!') - ) - return - - if dry_run: - self.stdout.write( - self.style.WARNING( - f'🔍 حالت Dry Run: {len(missing_participants)} شرکت‌کننده نیاز به ایجاد دارند.' - ) - ) - self.stdout.write( - 'برای اعمال تغییرات، دستور را بدون --dry-run اجرا کنید.' - ) - return - - # ایجاد شرکت‌کنندگان ناموجود - created_count = 0 - failed_count = 0 - - self.stdout.write( - self.style.SUCCESS(f'🚀 شروع ایجاد {len(missing_participants)} شرکت‌کننده...') - ) - - for trans in missing_participants: - try: - with transaction.atomic(): - # اضافه کردن نقش student اگر وجود نداشته باشد - if not trans.user.has_role('student'): - trans.user.add_role('student') - self.stdout.write( - f' 👤 نقش student به کاربر {trans.user.email} اضافه شد' - ) - - # ایجاد participant - participant = Participant.objects.create( - student=trans.user, - course=trans.course - ) - - created_count += 1 - self.stdout.write( - self.style.SUCCESS( - f' ✅ شرکت‌کننده ایجاد شد: {trans.user.email} در دوره "{trans.course.title}"' - ) - ) - - except Exception as e: - failed_count += 1 - self.stdout.write( - self.style.ERROR( - f' ❌ خطا در ایجاد شرکت‌کننده برای تراکنش {trans.id}: {str(e)}' - ) - ) - - # نمایش نتیجه نهایی - self.stdout.write('\n' + '='*50) - self.stdout.write('🏁 نتیجه نهایی:') - self.stdout.write(f' • شرکت‌کنندگان ایجاد شده: {created_count}') - self.stdout.write(f' • شکست‌ها: {failed_count}') - - if created_count > 0: - self.stdout.write( - self.style.SUCCESS(f'✅ {created_count} شرکت‌کننده با موفقیت ایجاد شد!') - ) - - if failed_count > 0: - self.stdout.write( - self.style.ERROR(f'❌ {failed_count} مورد با خطا مواجه شد!') - ) - - self.stdout.write('='*50) - - def get_transaction_info(self, trans): - """اطلاعات کامل تراکنش را برمی‌گرداند""" - return { - 'id': trans.id, - 'user_email': trans.user.email, - 'course_title': trans.course.title, - 'course_slug': trans.course.slug, - 'price': trans.price, - 'created_at': trans.created_at, - 'status': trans.status - } \ No newline at end of file diff --git a/apps/transaction/migrations/0001_initial.py b/apps/transaction/migrations/0001_initial.py deleted file mode 100644 index 3c50f2f..0000000 --- a/apps/transaction/migrations/0001_initial.py +++ /dev/null @@ -1,209 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-22 10:48 - -import apps.transaction.models -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import phonenumber_field.modelfields -import utils.validators - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("course", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="TransactionParticipant", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "payment_method", - models.CharField( - choices=[ - ("receipt", "Receipt"), - ("free", "Free"), - ("Payment_Gateway", "Payment Gateway"), - ], - default="Payment_Gateway", - max_length=20, - verbose_name="Transaction Payment Method", - ), - ), - ( - "price", - models.DecimalField( - decimal_places=2, - default=0.0, - max_digits=10, - verbose_name="Transaction Price", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("pending", "Pending"), - ("waiting_approval", "Waiting for Approval"), - ("success", "Success"), - ("failed", "Failed"), - ], - default="pending", - max_length=20, - verbose_name="Transaction Status", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created at"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="Updated at"), - ), - ("is_deleted", models.BooleanField(default=False)), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="course_transactions", - to="course.course", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="transactions", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="TransactionReceipt", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "file", - models.FileField( - help_text="Upload payment receipt image or document", - upload_to=apps.transaction.models.receipt_file_upload_to, - verbose_name="Receipt File", - ), - ), - ( - "uploaded_at", - models.DateTimeField(auto_now_add=True, verbose_name="Uploaded At"), - ), - ( - "description", - models.TextField( - blank=True, - help_text="Optional description or notes about the receipt", - null=True, - verbose_name="Description", - ), - ), - ( - "transaction", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="receipts", - to="transaction.transactionparticipant", - verbose_name="Transaction", - ), - ), - ], - options={ - "verbose_name": "Transaction Receipt", - "verbose_name_plural": "Transaction Receipts", - "ordering": ["-uploaded_at"], - }, - ), - migrations.CreateModel( - name="ParticipantInfo", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "fullname", - models.CharField( - help_text="Enter the full name of the user.", - max_length=255, - verbose_name="Full Name", - ), - ), - ( - "email", - models.EmailField( - help_text="Enter the user's email address.", - max_length=254, - verbose_name="Email Address", - ), - ), - ( - "phone_number", - phonenumber_field.modelfields.PhoneNumberField( - blank=True, - max_length=128, - null=True, - region=None, - validators=[utils.validators.validate_possible_number], - verbose_name="phone", - ), - ), - ( - "gender", - models.CharField( - blank=True, - choices=[("male", "Male"), ("female", "Female")], - help_text="Select the user's gender.", - max_length=20, - null=True, - verbose_name="Gender", - ), - ), - ( - "birthdate", - models.DateField(blank=True, null=True, verbose_name="birthdate"), - ), - ( - "transaction_participant", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="participant_infos", - to="transaction.transactionparticipant", - verbose_name="Transaction Participant", - ), - ), - ], - ), - ] diff --git a/apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py b/apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py deleted file mode 100644 index 3bc0270..0000000 --- a/apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 5.2.12 on 2026-05-03 14:09 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0004_alter_lessoncompletion_options_and_more'), - ('transaction', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterModelOptions( - name='participantinfo', - options={'verbose_name': 'Participant Info', 'verbose_name_plural': 'Participant Infos'}, - ), - migrations.AlterModelOptions( - name='transactionparticipant', - options={'verbose_name': 'Transaction Participant', 'verbose_name_plural': 'Transaction Participants'}, - ), - migrations.AlterField( - model_name='transactionparticipant', - name='course', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_transactions', to='course.course', verbose_name='Course'), - ), - migrations.AlterField( - model_name='transactionparticipant', - name='is_deleted', - field=models.BooleanField(default=False, verbose_name='Is Deleted'), - ), - migrations.AlterField( - model_name='transactionparticipant', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL, verbose_name='User'), - ), - ] diff --git a/apps/transaction/migrations/__init__.py b/apps/transaction/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/transaction/models.py b/apps/transaction/models.py deleted file mode 100644 index 46b603e..0000000 --- a/apps/transaction/models.py +++ /dev/null @@ -1,127 +0,0 @@ -from django.db import models -import os - -from django.utils.translation import gettext_lazy as _ - -from apps.account.models import StudentUser, User -from apps.course.models import Course -from phonenumber_field.modelfields import PhoneNumberField -from utils.validators import validate_possible_number - - -def receipt_file_upload_to(instance, filename): - return os.path.join(f"receipts/{instance.transaction.id}/{filename}") - - -class TransactionParticipant(models.Model): - - - class TransactionStatus(models.TextChoices): - PENDING = 'pending', _('Pending') - WAITING_APPROVAL = 'waiting_approval', _('Waiting for Approval') - SUCCESS = 'success', _('Success') - FAILED = 'failed', _('Failed') - - class PaymentMethods(models.TextChoices): - RECEIPT = 'receipt', _('Receipt') - FREE = 'free', _('Free') - PAYMENT_GATEWAY = 'Payment_Gateway', _('Payment Gateway') - - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='transactions', verbose_name=_('User')) - course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_transactions', verbose_name=_('Course')) - payment_method=models.CharField(max_length=20, choices=PaymentMethods.choices, default=PaymentMethods.PAYMENT_GATEWAY, verbose_name=_('Transaction Payment Method')) - # is_paid = models.BooleanField(default=False, verbose_name='Payment Status', help_text='Indicates whether the payment has been completed or not') - price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Transaction Price')) - status = models.CharField(max_length=20, choices=TransactionStatus.choices, default=TransactionStatus.PENDING, verbose_name=_('Transaction Status')) - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) - is_deleted = models.BooleanField(default=False, verbose_name=_('Is Deleted')) - - def __str__(self): - return f"{self.user.email} - {self.course.title} ({self.status})" - - def is_participant_enrolled(self): - """بررسی اینکه آیا کاربر در دوره ثبت‌نام شده یا نه""" - from apps.course.models import Participant - return Participant.objects.filter( - student=self.user, - course=self.course - ).exists() - - def get_participant(self): - """دریافت شرکت‌کننده دوره اگر وجود داشته باشد""" - from apps.course.models import Participant - return Participant.objects.filter( - student=self.user, - course=self.course - ).first() - - class Meta: - verbose_name = _('Transaction Participant') - verbose_name_plural = _('Transaction Participants') - - -class ParticipantInfo(models.Model): - class GenderChoices(models.TextChoices): - MALE = 'male', _('Male') - FEMALE = 'female', _('Female') - - transaction_participant = models.ForeignKey( - TransactionParticipant, - on_delete=models.CASCADE, - related_name='participant_infos', - verbose_name=_("Transaction Participant") - ) - fullname = models.CharField(max_length=255, verbose_name=_("Full Name"), help_text=_("Enter the full name of the user.")) - email = models.EmailField(verbose_name=_("Email Address"), help_text=_("Enter the user's email address.")) - phone_number = PhoneNumberField(validators=[validate_possible_number], null=True, blank=True, verbose_name=_('phone')) - gender = models.CharField( - max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text=_("Select the user's gender.") - ) - birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) - - def __str__(self): - return f"{self.fullname} (Transaction: {self.transaction_participant.id}) - {self.email}" - - class Meta: - verbose_name = _('Participant Info') - verbose_name_plural = _('Participant Infos') - - -class TransactionReceipt(models.Model): - """ - Model for storing payment receipts uploaded by users for transactions - """ - transaction = models.ForeignKey( - TransactionParticipant, - on_delete=models.CASCADE, - related_name='receipts', - verbose_name=_('Transaction') - ) - file = models.FileField( - upload_to=receipt_file_upload_to, - verbose_name=_('Receipt File'), - help_text=_('Upload payment receipt image or document') - ) - uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Uploaded At')) - description = models.TextField( - blank=True, - null=True, - verbose_name=_('Description'), - help_text=_('Optional description or notes about the receipt') - ) - - class Meta: - verbose_name = _('Transaction Receipt') - verbose_name_plural = _('Transaction Receipts') - ordering = ['-uploaded_at'] - - def __str__(self): - return f"Receipt for Transaction #{self.transaction.id} - {self.uploaded_at.strftime('%Y-%m-%d %H:%M')}" - - - - - - - diff --git a/apps/transaction/serializers.py b/apps/transaction/serializers.py deleted file mode 100644 index f3c91f7..0000000 --- a/apps/transaction/serializers.py +++ /dev/null @@ -1,96 +0,0 @@ - -from rest_framework import serializers - -from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt -from apps.course.serializers import CourseDetailSerializer -from utils import FileFieldSerializer - - - - -class ParticipantInfoSerializer(serializers.ModelSerializer): - phone_number = serializers.CharField(max_length=30) - - class Meta: - model = ParticipantInfo - fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate'] - - def validate_phone_number(self, value): - return value - - -class TransactionParticipantSerializer(serializers.ModelSerializer): - participant_infos = ParticipantInfoSerializer(many=True) - - class Meta: - model = TransactionParticipant - fields = ['participant_infos'] - - - def create(self, validated_data): - participant_infos_data = validated_data.pop('participant_infos', []) - transaction_participant = TransactionParticipant.objects.create(**validated_data) - - for participant_info_data in participant_infos_data: - ParticipantInfo.objects.create(transaction_participant=transaction_participant, **participant_info_data) - - return transaction_participant - - - -class TransactionListSerializer(serializers.ModelSerializer): - course = serializers.SerializerMethodField() - receipts = serializers.SerializerMethodField() - - class Meta: - model = TransactionParticipant - fields = ['id', 'course', 'status', 'price', 'receipts', 'created_at', 'updated_at'] - - def get_course(self, obj): - return CourseDetailSerializer(obj.course, context=self.context).data - - def get_receipts(self, obj): - receipts = obj.receipts.all() - return TransactionReceiptSerializer(receipts, many=True, context=self.context).data - - -class TransactionReceiptSerializer(serializers.ModelSerializer): - """ - Serializer for uploading payment receipts - Uses FileFieldSerializer to handle pre-uploaded files from /upload-tmp-media/ - """ - file = FileFieldSerializer() - - class Meta: - model = TransactionReceipt - fields = ['id', 'file', 'description', 'uploaded_at'] - read_only_fields = ['id', 'uploaded_at'] - - -class UploadReceiptsSerializer(serializers.Serializer): - """ - Serializer for uploading multiple receipt files for a transaction. - Files should be pre-uploaded using /upload-tmp-media/ endpoint, - then their URLs should be sent here. - """ - files = serializers.ListField( - child=FileFieldSerializer(), - allow_empty=False, - max_length=10, - help_text="List of file URLs (max 10 files) - files should be pre-uploaded via /upload-tmp-media/" - ) - description = serializers.CharField( - required=False, - allow_blank=True, - max_length=1000, - help_text="Optional description for the receipts" - ) - - def validate_files(self, files): - """ - Validate uploaded file URLs - """ - if len(files) > 10: - raise serializers.ValidationError("You can upload a maximum of 10 files.") - - return files \ No newline at end of file diff --git a/apps/transaction/signals.py b/apps/transaction/signals.py deleted file mode 100644 index fa42c8c..0000000 --- a/apps/transaction/signals.py +++ /dev/null @@ -1,67 +0,0 @@ -from django.db.models.signals import post_save, pre_save -from django.dispatch import receiver -from django.db import transaction - -from apps.transaction.models import TransactionParticipant -from apps.course.models import Participant - - -@receiver(pre_save, sender=TransactionParticipant) -def store_previous_status(sender, instance, **kwargs): - """ - Store the previous status before saving to compare with new status - """ - if instance.pk: - try: - previous_instance = TransactionParticipant.objects.get(pk=instance.pk) - instance._previous_status = previous_instance.status - except TransactionParticipant.DoesNotExist: - instance._previous_status = None - else: - instance._previous_status = None - - -@receiver(post_save, sender=TransactionParticipant) -def create_participant_on_success(sender, instance, created, **kwargs): - """ - Create course participant when transaction status changes to SUCCESS - """ - # اگر تراکنش جدید ایجاد شده و وضعیت آن SUCCESS است - if created and instance.status == TransactionParticipant.TransactionStatus.SUCCESS: - create_course_participant(instance) - - # اگر تراکنش موجود بوده و وضعیت آن از حالت دیگری به SUCCESS تغییر کرده - elif not created and hasattr(instance, '_previous_status'): - if (instance._previous_status != TransactionParticipant.TransactionStatus.SUCCESS and - instance.status == TransactionParticipant.TransactionStatus.SUCCESS): - create_course_participant(instance) - - -def create_course_participant(transaction_instance): - """ - Create course participant for successful transaction - """ - try: - with transaction.atomic(): - # بررسی اینکه آیا کاربر نقش student دارد یا نه - if not transaction_instance.user.has_role('student'): - transaction_instance.user.add_role('student') - - # بررسی اینکه آیا قبلاً participant ایجاد شده یا نه - existing_participant = Participant.objects.filter( - student_id=transaction_instance.user.id, - course=transaction_instance.course - ).first() - - if not existing_participant: - # ایجاد participant جدید - participant = Participant.objects.create( - student=transaction_instance.user, - course=transaction_instance.course - ) - print(f"Course participant created: {participant}") - else: - print(f"Course participant already exists: {existing_participant}") - - except Exception as e: - print(f"Error creating course participant: {e}") \ No newline at end of file diff --git a/apps/transaction/tests.py b/apps/transaction/tests.py deleted file mode 100644 index 08f1014..0000000 --- a/apps/transaction/tests.py +++ /dev/null @@ -1,137 +0,0 @@ -from django.test import TestCase -from django.contrib.auth import get_user_model -from apps.account.models import StudentUser -from apps.course.models import Course, CourseCategory, Participant -from apps.transaction.models import TransactionParticipant -from apps.account.models import ProfessorUser - -User = get_user_model() - - -class TransactionParticipantSignalTest(TestCase): - def setUp(self): - """تنظیمات اولیه برای تست""" - # ایجاد کاربر - self.user = User.objects.create_user( - email='test@example.com', - password='testpass123' - ) - - # ایجاد استاد - self.professor = ProfessorUser.objects.create( - email='professor@example.com', - password='testpass123' - ) - - # ایجاد دسته‌بندی دوره - self.category = CourseCategory.objects.create( - name='Test Category', - slug='test-category' - ) - - # ایجاد دوره - self.course = Course.objects.create( - title='Test Course', - slug='test-course', - category=self.category, - professor=self.professor, - video_type='youtube_link', - level='beginner', - duration=10, - lessons_count=5, - description='Test course description', - is_free=False, - price=100.00, - final_price=100.00 - ) - - def test_participant_created_on_success_status(self): - """تست ایجاد participant هنگام تغییر وضعیت به SUCCESS""" - # ایجاد تراکنش با وضعیت PENDING - transaction = TransactionParticipant.objects.create( - user=self.user, - course=self.course, - price=100.00, - status=TransactionParticipant.TransactionStatus.PENDING - ) - - # بررسی که participant ایجاد نشده - self.assertFalse( - Participant.objects.filter(student=self.user, course=self.course).exists() - ) - - # تغییر وضعیت به SUCCESS - transaction.status = TransactionParticipant.TransactionStatus.SUCCESS - transaction.save() - - # بررسی که participant ایجاد شده - self.assertTrue( - Participant.objects.filter(student=self.user, course=self.course).exists() - ) - - # بررسی که کاربر نقش student دارد - self.assertTrue(self.user.has_role('student')) - - def test_participant_created_on_direct_success(self): - """تست ایجاد participant هنگام ایجاد تراکنش با وضعیت SUCCESS""" - # ایجاد تراکنش مستقیماً با وضعیت SUCCESS - transaction = TransactionParticipant.objects.create( - user=self.user, - course=self.course, - price=100.00, - status=TransactionParticipant.TransactionStatus.SUCCESS - ) - - # بررسی که participant ایجاد شده - self.assertTrue( - Participant.objects.filter(student=self.user, course=self.course).exists() - ) - - # بررسی که کاربر نقش student دارد - self.assertTrue(self.user.has_role('student')) - - def test_no_duplicate_participant(self): - """تست عدم ایجاد participant تکراری""" - # ایجاد participant دستی - existing_participant = Participant.objects.create( - student=self.user, - course=self.course - ) - - # ایجاد تراکنش با وضعیت SUCCESS - transaction = TransactionParticipant.objects.create( - user=self.user, - course=self.course, - price=100.00, - status=TransactionParticipant.TransactionStatus.SUCCESS - ) - - # بررسی که فقط یک participant وجود دارد - self.assertEqual( - Participant.objects.filter(student=self.user, course=self.course).count(), - 1 - ) - - def test_model_helper_methods(self): - """تست متدهای کمکی مدل""" - # ایجاد تراکنش - transaction = TransactionParticipant.objects.create( - user=self.user, - course=self.course, - price=100.00, - status=TransactionParticipant.TransactionStatus.PENDING - ) - - # بررسی که participant وجود ندارد - self.assertFalse(transaction.is_participant_enrolled()) - self.assertIsNone(transaction.get_participant()) - - # ایجاد participant - participant = Participant.objects.create( - student=self.user, - course=self.course - ) - - # بررسی که participant وجود دارد - self.assertTrue(transaction.is_participant_enrolled()) - self.assertEqual(transaction.get_participant(), participant) diff --git a/apps/transaction/urls.py b/apps/transaction/urls.py deleted file mode 100644 index 7614de9..0000000 --- a/apps/transaction/urls.py +++ /dev/null @@ -1,16 +0,0 @@ - -from django.urls import path, re_path - -from . import views - - - -urlpatterns = [ - re_path(r'(?P[\w-]+)/join/$', views.TransactionParticipantCreateView.as_view(), name='transaction-participant-create'), - path('list/', views.TransactiontListView.as_view(), name='transaction-list'), - path('/delete/', views.SoftDeleteTransactionParticipantView.as_view(), name='soft-delete-transaction-participant'), - path('/receipts/upload/', views.UploadTransactionReceiptsView.as_view(), name='upload-transaction-receipts'), - path('/receipts/', views.TransactionReceiptsListView.as_view(), name='transaction-receipts-list'), - -] - \ No newline at end of file diff --git a/apps/transaction/views.py b/apps/transaction/views.py deleted file mode 100644 index adde1d1..0000000 --- a/apps/transaction/views.py +++ /dev/null @@ -1,412 +0,0 @@ -from rest_framework import generics, status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework.authentication import TokenAuthentication -from apps.course.models import Participant, Course -from apps.transaction.models import TransactionParticipant, TransactionReceipt -from apps.transaction.serializers import ( - TransactionParticipantSerializer, - TransactionListSerializer, - UploadReceiptsSerializer, - TransactionReceiptSerializer -) -from utils.exceptions import AppAPIException -from apps.account.models import User -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from apps.transaction.models import TransactionParticipant -from apps.transaction.doc import ( - doc_upload_transaction_receipts, - doc_list_transaction_receipts, - doc_transaction_list, - doc_create_transaction -) - -from utils.ip_helper import get_client_ip, get_country_code - - - -class TransactionParticipantCreateView(generics.CreateAPIView): - queryset = TransactionParticipant.objects.all() - serializer_class = TransactionParticipantSerializer - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - - @swagger_auto_schema( - operation_description=doc_create_transaction(), - responses={ - status.HTTP_201_CREATED: openapi.Response( - description="Transaction participant created successfully", - examples={ - "application/json": { - "message": "Transaction Participant created successfully.", - "transaction_id": 374, - "payment_method": "Payment_Gateway", - "payment_link": "https://russia-payment.com/pay/374", - "participant_infos": [ - { - "fullname": "string", - "email": "admin@gmail.com", - "phone_number": "string", - "gender": "male", - "birthdate": "2025-12-28" - } - ] - } - }, - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'message': openapi.Schema(type=openapi.TYPE_STRING, description="Success message"), - 'transaction_id': openapi.Schema(type=openapi.TYPE_INTEGER, description="Unique transaction identifier"), - 'payment_method': openapi.Schema( - type=openapi.TYPE_STRING, - enum=['Payment_Gateway', 'receipt'], - description="Payment method: 'Payment_Gateway' for online payment, 'receipt' for WhatsApp upload" - ), - 'payment_link': openapi.Schema( - type=openapi.TYPE_STRING, - nullable=True, - description="Payment gateway URL (only present when payment_method is 'Payment_Gateway')" - ), - 'participant_infos': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'fullname': openapi.Schema(type=openapi.TYPE_STRING), - 'email': openapi.Schema(type=openapi.TYPE_STRING), - 'phone_number': openapi.Schema(type=openapi.TYPE_STRING), - 'gender': openapi.Schema(type=openapi.TYPE_STRING, enum=['male', 'female']), - 'birthdate': openapi.Schema(type=openapi.TYPE_STRING, format='date'), - } - ), - description="List of participant information" - ), - }, - required=['message', 'transaction_id', 'participant_infos'] - ) - ), - status.HTTP_400_BAD_REQUEST: openapi.Response( - description="Invalid data provided", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'detail': openapi.Schema(type=openapi.TYPE_STRING), - } - ) - ), - status.HTTP_404_NOT_FOUND: openapi.Response( - description="Course not found", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'message': openapi.Schema(type=openapi.TYPE_STRING), - } - ) - ), - }, - tags=['Imam-Javad - Transaction'] - ) - def post(self, request, *args, **kwargs): - # Simply call the create method - return self.create(request, *args, **kwargs) - - def create(self, request, *args, **kwargs): - user = request.user - course_slug = self.kwargs.get('slug') - - # 1. Retrieve Course - try: - course = Course.objects.get(slug=course_slug) - except Course.DoesNotExist: - raise AppAPIException({'message': "Course not found"}) - - participant_infos = request.data.get('participant_infos', []) - - # 2. Validate and Initialize - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - statis = TransactionParticipant.TransactionStatus.PENDING - - # 3. Handle Free/Self-Enrollment Logic - if len(participant_infos) == 1 and (course.final_price == 0 or course.is_free): - participant = participant_infos[0] - if participant.get('email') != user.email: - raise AppAPIException({'message': "The email must be for the requesting user"}) - - if not user.has_role('student'): - user.add_role('student') - - existing_participant = Participant.objects.filter(student=user, course=course).first() - if existing_participant: - participant = existing_participant - else: - participant = Participant.objects.create(student=user, course=course) - statis = TransactionParticipant.TransactionStatus.SUCCESS - - # 4. Save Transaction - transaction_participant = serializer.save( - user=user, - course=course, - price=course.final_price, - status=statis - ) - print(f'---> {type(transaction_participant)}/ {transaction_participant}') - - # ======================================================= - # NEW LOGIC: HYBRID GEOLOCATION CHECK (Cloudflare + Local DB) - # ======================================================= - - payment_link = None - - payment_method = TransactionParticipant.PaymentMethods.FREE - if statis == TransactionParticipant.TransactionStatus.PENDING: - - # Step A: Fast Path - Check Cloudflare Header - # Cloudflare sends the 2-letter code (e.g., 'RU', 'US') in this header - country_code = request.META.get('HTTP_CF_IPCOUNTRY') - - # Step B: Slow Path - Fallback to Local DB - # If header is missing (e.g., Localhost, direct connection, or CF failed) - if not country_code: - try: - client_ip =get_client_ip(request) - # "188.93.104.1" - # get_client_ip(request) - # Assuming your helper handles errors gracefully and returns None - country_code = get_country_code(client_ip) - except Exception as e: - print(f"GeoIP Lookup Failed: {e}") - country_code = None - payment_method = TransactionParticipant.PaymentMethods.RECEIPT - # Step C: Apply Logic - if country_code != 'RU': - payment_method = TransactionParticipant.PaymentMethods.PAYMENT_GATEWAY - payment_link = f"https://russia-payment.com/pay/{transaction_participant.id}" - - # Uncomment if you want a global fallback link - # else: - # payment_link = f"https://global-payment.com/pay/{transaction_participant.id}" - - # ======================================================= - - return Response({ - 'message': 'Transaction Participant created successfully.', - 'transaction_id': transaction_participant.id, - 'payment_method':payment_method, - 'payment_link': payment_link, - 'participant_infos': serializer.data['participant_infos'] - }, status=status.HTTP_201_CREATED) - - - - -from utils.pagination import StandardResultsSetPagination - - - -class TransactiontListView(generics.ListAPIView): - queryset = TransactionParticipant.objects.all() - serializer_class = TransactionListSerializer - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - pagination_class = StandardResultsSetPagination - - @swagger_auto_schema( - operation_description=doc_transaction_list(), - tags=['Imam-Javad - Transaction'] - ) - def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) - def get_queryset(self): - queryset = super().get_queryset() - queryset = queryset.filter(user=self.request.user, is_deleted=False) - return queryset - - - -class SoftDeleteTransactionParticipantView(APIView): - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - - @swagger_auto_schema( - operation_summary="Soft delete a transaction participant", - operation_description="Marks a transaction participant as deleted without removing it from the database", - tags=['Imam-Javad - Transaction'], - manual_parameters=[ - openapi.Parameter( - 'id', - openapi.IN_PATH, - description="Transaction Participant ID", - type=openapi.TYPE_INTEGER, - required=True - ) - ], - responses={ - 200: openapi.Response( - description="Transaction participant successfully marked as deleted", - examples={ - "application/json": { - "success": True, - "message": "Transaction participant successfully marked as deleted" - } - } - ), - 404: "Transaction participant not found", - 403: "Permission denied" - } - ) - def delete(self, request, pk): - try: - transaction = TransactionParticipant.objects.get(pk=pk) - if transaction.user == request.user: - transaction.is_deleted = True - transaction.save() - return Response({ - "success": True, - "message": "Transaction participant successfully marked as deleted" - }, status=status.HTTP_200_OK) - else: - raise AppAPIException( - detail={'message': "You don't have permission to delete this transaction"}, - status_code=status.HTTP_403_FORBIDDEN - ) - - except TransactionParticipant.DoesNotExist: - raise AppAPIException({'message': "Transaction participant not found"}) - - -class UploadTransactionReceiptsView(APIView): - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - - @swagger_auto_schema( - operation_summary="Upload payment receipts for a transaction", - operation_description=doc_upload_transaction_receipts(), - tags=['Imam-Javad - Transaction'], - request_body=UploadReceiptsSerializer, - responses={ - 201: openapi.Response( - description="Receipts uploaded successfully", - examples={ - "application/json": { - "success": True, - "message": "Receipts uploaded successfully", - "transaction_status": "waiting_approval", - "receipts": [ - { - "id": 1, - "file": "http://example.com/media/receipts/1/receipt.jpg", - "description": "Payment receipt", - "uploaded_at": "2025-12-03T10:30:00Z" - } - ] - } - } - ), - 400: "Invalid data or transaction cannot accept receipts", - 403: "Permission denied", - 404: "Transaction not found" - } - ) - def post(self, request, transaction_id): - try: - transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False) - except TransactionParticipant.DoesNotExist: - raise AppAPIException({'message': "Transaction not found"}) - - # Check if user owns this transaction - if transaction.user != request.user: - raise AppAPIException( - detail={'message': "You don't have permission to upload receipts for this transaction"}, - status_code=status.HTTP_403_FORBIDDEN - ) - - # Check if transaction is in a state that can accept receipts - if transaction.status not in [ - TransactionParticipant.TransactionStatus.PENDING, - TransactionParticipant.TransactionStatus.WAITING_APPROVAL - ]: - raise AppAPIException( - detail={'message': f"Cannot upload receipts for transaction with status '{transaction.status}'"}, - status_code=status.HTTP_400_BAD_REQUEST - ) - - # Validate using serializer - serializer = UploadReceiptsSerializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) - - # Create receipt records - receipts = [] - description = serializer.validated_data.get('description', '') - file_urls = serializer.validated_data.get('files', []) - - for file_url in file_urls: - receipt = TransactionReceipt.objects.create( - transaction=transaction, - file=file_url, - description=description - ) - receipts.append(receipt) - - # Update transaction status to waiting_approval - transaction.status = TransactionParticipant.TransactionStatus.WAITING_APPROVAL - transaction.save() - - # Serialize receipts for response - receipts_data = TransactionReceiptSerializer(receipts, many=True, context={'request': request}).data - - return Response({ - 'success': True, - 'message': 'Receipts uploaded successfully', - 'transaction_status': transaction.status, - 'receipts': receipts_data - }, status=status.HTTP_201_CREATED) - - -class TransactionReceiptsListView(generics.ListAPIView): - serializer_class = TransactionReceiptSerializer - permission_classes = [IsAuthenticated] - authentication_classes = [TokenAuthentication] - pagination_class = StandardResultsSetPagination - - @swagger_auto_schema( - operation_summary="List receipts for a transaction", - operation_description=doc_list_transaction_receipts(), - tags=['Imam-Javad - Transaction'], - manual_parameters=[ - openapi.Parameter( - 'transaction_id', - openapi.IN_PATH, - description="Transaction ID", - type=openapi.TYPE_INTEGER, - required=True - ) - ], - responses={ - 200: TransactionReceiptSerializer(many=True), - 403: "Permission denied", - 404: "Transaction not found" - } - ) - def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) - - def get_queryset(self): - transaction_id = self.kwargs.get('transaction_id') - - try: - transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False) - except TransactionParticipant.DoesNotExist: - raise AppAPIException({'message': "Transaction not found"}) - - # Check if user owns this transaction - if transaction.user != self.request.user: - raise AppAPIException( - detail={'message': "You don't have permission to view receipts for this transaction"}, - status_code=status.HTTP_403_FORBIDDEN - ) - - return TransactionReceipt.objects.filter(transaction=transaction) diff --git a/apps/video/tests.py b/apps/video/tests.py index 7ce503c..ee3a129 100644 --- a/apps/video/tests.py +++ b/apps/video/tests.py @@ -1,3 +1,50 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase -# Create your tests here. + +class VideoURLResolutionTests(APITestCase): + """ + Test suite to ensure all video API endpoints resolve and execute cleanly. + """ + + def test_category_list_endpoint(self): + """Test video categories endpoint is accessible""" + url = reverse('video:category-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_pinned_collection_list_endpoint(self): + """Test video pinned collections endpoint is accessible""" + url = reverse('video:pinned-collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_collection_list_endpoint(self): + """Test video collections endpoint is accessible""" + url = reverse('video:collection-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_playlist_list_endpoint(self): + """Test video playlists endpoint is accessible""" + url = reverse('video:playlist-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_playlist_detail_endpoint(self): + """Test video playlist detail endpoint is accessible (may return 404 if no data)""" + url = reverse('video:playlist-detail', kwargs={'slug': 'test-playlist'}) + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_video_list_endpoint(self): + """Test video list endpoint is accessible""" + url = reverse('video:video-list') + response = self.client.get(url) + self.assertLess(response.status_code, 500) + + def test_video_detail_endpoint(self): + """Test video detail endpoint is accessible (may return 404 if no data)""" + url = reverse('video:video-detail', kwargs={'slug': 'test-video'}) + response = self.client.get(url) + self.assertLess(response.status_code, 500) diff --git a/config/middleware/site_middleware.py b/config/middleware/site_middleware.py deleted file mode 100644 index 9e3a169..0000000 --- a/config/middleware/site_middleware.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Domain-based URL Configuration Middleware - -This middleware detects the request domain and routes to the appropriate -URLconf (URL configuration) for each site: -- Dovoodi domains → config.urls_dovoodi -- Imam Javad domains → config.urls_imamjavad -""" - - -class SiteMiddleware: - """ - Middleware to route requests to different URL configurations based on domain. - - This allows each domain to have clean /admin/ URLs instead of path-based - differentiation (/imam-javad/admin vs /dovoodi/admin). - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - host = request.get_host() - - # Check if the request is from Dovoodi domain - if 'dovodi' in host or 'dovoodi' in host: - request.urlconf = 'config.urls_dovoodi' - # Otherwise, use Imam Javad configuration (default) - else: - request.urlconf = 'config.urls_imamjavad' - - return self.get_response(request) diff --git a/config/settings/base.py b/config/settings/base.py index aaef522..a142041 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -46,11 +46,6 @@ X_FRAME_OPTIONS = 'SAMEORIGIN' LOCAL_APPS = [ 'apps.account.apps.AccountConfig', 'apps.api.apps.ApiConfig', - 'apps.course.apps.CourseConfig', - 'apps.chat.apps.ChatConfig', - 'apps.quiz.apps.QuizConfig', - 'apps.transaction.apps.TransactionConfig', - 'apps.certificate.apps.CertificateConfig', 'apps.hadis.apps.HadisConfig', 'apps.library.apps.LibraryConfig', 'apps.video.apps.VideoConfig', @@ -58,7 +53,6 @@ LOCAL_APPS = [ 'apps.bookmark.apps.BookmarkConfig', 'apps.article.apps.ArticleConfig', 'apps.dobodbi_calendar.apps.DobodbiCalendarConfig', - 'apps.blog.apps.BlogConfig', 'apps.agent.apps.AgentConfig', 'dynamic_preferences', 'apps.geolocation_package', @@ -122,7 +116,6 @@ PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' AUTH_USER_MODEL = "account.User" MIDDLEWARE = [ - 'config.middleware.site_middleware.SiteMiddleware', # Must be first to route by domain 'django.middleware.security.SecurityMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware", 'django.contrib.sessions.middleware.SessionMiddleware', @@ -325,21 +318,6 @@ LOGIN_REDIRECT_URL = reverse_lazy("home") ###################################################################### from utils.admin import admin_url_generator , is_dovoodi_panel , is_main_panel -# --- ENHANCED DYNAMIC BADGE FUNCTION --- -def get_pending_certificates_badge(request): - try: - from apps.certificate.models import Certificate - qs = Certificate.objects.filter(status='pending') - - # If user is a professor (not staff/admin), only show their pending certificates - if request.user.is_authenticated and not request.user.is_staff and not getattr(request.user, 'is_superuser', False): - qs = qs.filter(course__professor=request.user) - - count = qs.count() - return str(count) if count > 0 else None - except Exception as e: - print(f"Badge Error: {e}") # Fails safely in terminal if DB isn't migrated yet - return None UNFOLD = { # "SITE_TITLE": _("Imam Jawad Admin"), @@ -365,14 +343,14 @@ UNFOLD = { "ENVIRONMENT": "utils.environment_callback", "DASHBOARD_CALLBACK": "utils.admin.dashboard_callback", "SITE_ICON": { - "light": lambda request: static("images/dovoodi_logo.svg") if is_dovoodi_panel(request) else static("images/logo1.svg"), - "dark": lambda request: static("images/dovoodi_logo.svg") if is_dovoodi_panel(request) else static("images/logo1.svg"), + "light": lambda request: static("images/dovoodi_logo.svg"), + "dark": lambda request: static("images/dovoodi_logo.svg"), }, "SITE_SYMBOL": "speed", "SHOW_BACK_BUTTON": True, # show/hide "Back" button on changeform in header, default: False "THEME": "light", "LOGIN": { - "image": lambda request: static("images/dovoodi_login.png") if is_dovoodi_panel(request) else static("images/image1.jpg"), + "image": lambda request: static("images/dovoodi_login.png"), }, # ✅ COLORS حذف شد - هر AdminSite رنگ‌های خودش را در utils/admin.py تعریف می‌کند # - FormulaAdminSite: پالت سبز برای امام جواد @@ -470,78 +448,6 @@ UNFOLD = { }, ], }, - { - "page": "courses", - "models": [ - "course.course", - "course.courselesson", - "course.courseglossary", - "course.courseattachment", - "quiz.quiz", - "quiz.quizparticipant", - ], - "items": [ - { - "title": _("Courses"), - "icon": "school", - "link": lambda request: admin_url_generator(request, "course_course_changelist"), - "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_course_changelist"))), - }, - { - "title": _("Course Lessons"), - "icon": "menu_book", - "link": lambda request: admin_url_generator(request, "course_courselesson_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courselesson_changelist")), - }, - { - "title": _("Course Attachments"), - "icon": "attach_file", - "link": lambda request: admin_url_generator(request, "course_courseattachment_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courseattachment_changelist")), - }, - { - "title": _("Course Glossary"), - "icon": "book", - "link": lambda request: admin_url_generator(request, "course_courseglossary_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courseglossary_changelist")), - }, - { - "title": _("Quizzes"), - "icon": "quiz", - "link": lambda request: admin_url_generator(request, "quiz_quiz_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "quiz_quiz_changelist")), - }, - - ], - }, - { - "page": "course_online", - "models": [ - "course.courselivesession", - "course.livesessionuser", - "course.livesessionrecording", - ], - "items": [ - { - "title": _("Live Sessions"), - "icon": "video_call", - "link": lambda request: admin_url_generator(request, "course_courselivesession_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courselivesession_changelist")), - }, - { - "title": _("Session Users"), - "icon": "groups", - "link": lambda request: admin_url_generator(request, "course_livesessionuser_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "course_livesessionuser_changelist")), - }, - { - "title": _("Session Recordings"), - "icon": "play_circle", - "link": lambda request: admin_url_generator(request, "course_livesessionrecording_changelist"), - "active": lambda request: request.path.startswith(admin_url_generator(request, "course_livesessionrecording_changelist")), - }, - ], - }, { "page": "podcast", "models": ["podcast.podcastcollection", "podcast.pinnedpodcastcollection", "podcast.middlepodcastcollection"], @@ -601,81 +507,8 @@ UNFOLD = { "link": lambda request: admin_url_generator(request, "account_user_changelist"), "permission": lambda request: request.user.is_staff, }, - { - "title": _("Students"), - "icon": "face", - "link": lambda request: admin_url_generator(request, "account_studentuser_changelist"), - "permission": is_main_panel, - }, - { - "title": _("Professors"), - "icon": "history_edu", - "link": lambda request: admin_url_generator(request, "account_professoruser_changelist"), - "permission": is_main_panel, - }, ], }, - # --- 3. ACADEMICS (Collapsible) --- - { - "title": _("Courses"), - "collapsible": True, - "separator": True, - "permission": is_main_panel, - "items": [ - { - "title": _("Categories"), - "icon": "category", - "link": lambda request: admin_url_generator(request, "course_coursecategory_changelist"), - "permission": is_main_panel, - }, - { - "title": _("Courses"), - "icon": "school", - "link": lambda request: admin_url_generator(request, "course_course_changelist"), - "permission": is_main_panel, - }, - { - "title": _("Live Sessions"), - "icon": "video_camera_front", - "link": lambda request: admin_url_generator(request, "course_courselivesession_changelist"), - "permission": is_main_panel, - }, - { - "title": _("Certificates"), - "icon": "workspace_premium", - "link": lambda request: admin_url_generator(request, "certificate_certificate_changelist"), - "permission": is_main_panel, - "badge": "utils.admin.get_pending_certificates_badge", - }, - ] - }, - - # --- 4. ASSESSMENTS --- - { - "title": _(""), - "separator": True, - "permission": is_main_panel, - "items": [ - { - "title": _("Transactions"), - "icon": "payments", - "link": lambda request: admin_url_generator(request, "transaction_transactionparticipant_changelist"), - "permission": is_main_panel, - }, - { - "title": _("Chat Rooms"), - "icon": "forum", - "link": lambda request: admin_url_generator(request, "chat_roommessage_changelist"), - "permission": is_main_panel, - }, - { - "title": _("Blogs"), - "icon": "article", - "link": lambda request: admin_url_generator(request, "blog_blog_changelist"), - "permission": is_main_panel, - }, - ] - }, # --- DOVOODI SECTIONS --- { "title": _("Libraries"), diff --git a/config/urls.py b/config/urls.py index a66020b..bd03a34 100644 --- a/config/urls.py +++ b/config/urls.py @@ -29,7 +29,7 @@ from rest_framework.response import Response from utils import absolute_url -from utils.admin import project_admin_site, HomeView ,dovoodi_admin_site +from utils.admin import dovoodi_admin_site from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -72,10 +72,11 @@ api_patterns = [ path('test/', include('apps.api.urls')), path('account/', include('apps.account.urls')), - path('courses/', include('apps.course.urls')), - path('quiz/', include('apps.quiz.urls')), - path('transaction/', include('apps.transaction.urls')), - path('certificates/', include('apps.certificate.urls')), + # FIXME: DEPENDENCY ON DELETED APPS (course, quiz, transaction, certificate, blog) — Commented out for Dovoodi project separation + # path('courses/', include('apps.course.urls')), + # path('quiz/', include('apps.quiz.urls')), + # path('transaction/', include('apps.transaction.urls')), + # path('certificates/', include('apps.certificate.urls')), path('hadis/', include('apps.hadis.urls')), path('library/', include('apps.library.urls')), path('videos/', include('apps.video.urls')), @@ -83,7 +84,7 @@ api_patterns = [ path('podcast/', include('apps.podcast.urls')), path('bookmarks/', include('apps.bookmark.urls')), path('calendar/', include('apps.dobodbi_calendar.urls')), - path('blog/', include('apps.blog.urls')), + # path('blog/', include('apps.blog.urls')), path('settings/', include('dynamic_preferences.urls')), @@ -99,16 +100,6 @@ api_patterns = [ def trigger_error(request): division_by_zero = 1 / 0 -urlpatterns = [ - path("admin/", HomeView.as_view(), name="home"), # Redirect to appropriate admin based on domain - path("i18n/", include("django.conf.urls.i18n")), - path('api/', include(api_patterns)), - path('oneapi-translation/', oneapi_translate), - path('admin/filer/', include('filer.urls')), - path('filer/', include('filer.urls')), - path('sentry-debug/', trigger_error), -] - # Protected swagger URL patterns (to be used in domain-specific configs) swagger_urlpatterns = [ path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'), @@ -122,6 +113,26 @@ swagger_urlpatterns = [ name='schema-redoc'), ] +urlpatterns = [ + path("i18n/", include("django.conf.urls.i18n")), + path('api/', include(api_patterns)), + path('oneapi-translation/', oneapi_translate), + path('sentry-debug/', trigger_error), +] + i18n_patterns( + # Unified Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/) + path("admin/", dovoodi_admin_site.urls), + + # API documentation + path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), + + # Swagger and API documentation + *swagger_urlpatterns, + + # Filer (Django file manager) + path('admin/filer/', include('filer.urls')), + path('filer/', include('filer.urls')), +) + if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/urls_dovoodi.py b/config/urls_dovoodi.py deleted file mode 100644 index 8fb77a6..0000000 --- a/config/urls_dovoodi.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -URL configuration for Dovoodi domain -This configuration is loaded when accessing from dovodi.* or dovoodi.* domains -""" -from django.urls import path, include -from django.conf.urls.i18n import i18n_patterns -from config.urls import urlpatterns as base_urlpatterns, swagger_urlpatterns -from utils.admin import dovoodi_admin_site -from apps.api.views import CustomAPIDocumentationView - - -# Combine base patterns with Dovoodi specific admin -urlpatterns = base_urlpatterns + i18n_patterns( - # Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/) - path("admin/", dovoodi_admin_site.urls), - - # API documentation - path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), - - # Swagger and API documentation - *swagger_urlpatterns, - - # Filer (Django file manager) - path('admin/filer/', include('filer.urls')), - path('filer/', include('filer.urls')), -) diff --git a/config/urls_imamjavad.py b/config/urls_imamjavad.py deleted file mode 100644 index e5bface..0000000 --- a/config/urls_imamjavad.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -URL configuration for Imam Javad domain -This configuration is loaded when accessing from imamjavad.* domains -""" -from django.urls import path, include -from django.conf.urls.i18n import i18n_patterns -from config.urls import urlpatterns as base_urlpatterns, swagger_urlpatterns -from utils.admin import project_admin_site -from apps.api.views import CustomAPIDocumentationView - - -# Combine base patterns with Imam Javad specific admin -urlpatterns = base_urlpatterns + i18n_patterns( - # Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/) - path("admin/", project_admin_site.urls), - - # API documentation - path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), - - # Swagger and API documentation - *swagger_urlpatterns, - - # Filer (Django file manager) - path('admin/filer/', include('filer.urls')), - path('filer/', include('filer.urls')), -) diff --git a/create_live_room.sh b/create_live_room.sh deleted file mode 100755 index 7568f71..0000000 --- a/create_live_room.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash - -set -e - -API_BASE="https://imamjavad.newhorizonco.uk/api" -LIVE_BASE="https://live.newhorizonco.uk" - -DEFAULT_COURSE_SLUG="test-1-ghorbani" -DEFAULT_AUTH_TOKEN="e5ec00c7660302c3225276eaf7be99459c9a7012" - -AUTH_TOKEN="$DEFAULT_AUTH_TOKEN" -COURSE_SLUG="$DEFAULT_COURSE_SLUG" - -print_usage() { - echo "Usage: $0 [-t ] [-s ] [-h]" - echo "" - echo "Options:" - echo " -t User authentication token (default: test-1-ghorbani user token)" - echo " -s Course slug (default: test-1-ghorbani)" - echo " -h Show this help message" - echo "" - echo "Example:" - echo " $0" - echo " $0 -s my-course" - echo " $0 -t custom-token -s custom-course" -} - -while getopts "t:s:h" opt; do - case $opt in - t) AUTH_TOKEN="$OPTARG" ;; - s) COURSE_SLUG="$OPTARG" ;; - h) print_usage; exit 0 ;; - *) print_usage; exit 1 ;; - esac -done - -echo "✓ Using authentication token" -echo "" - -echo "Step 1: Creating live session room..." -ROOM_RESPONSE=$(curl -s -X POST "$API_BASE/courses/$COURSE_SLUG/online/room/create/" \ - -H "Content-Type: application/json" \ - -H "Authorization: Token $AUTH_TOKEN" \ - -d '{}') - -CREATED_ROOM_ID=$(echo "$ROOM_RESPONSE" | grep -o '"room_id":"[^"]*' | cut -d'"' -f4 | head -1) - -if [ -z "$CREATED_ROOM_ID" ]; then - echo "Error: Failed to create room" - echo "Response: $ROOM_RESPONSE" - exit 1 -fi - -echo "✓ Room created: $CREATED_ROOM_ID" -echo "" - -echo "Step 2: Getting join token..." -TOKEN_RESPONSE=$(curl -s -X POST "$API_BASE/courses/online/room/token/" \ - -H "Content-Type: application/json" \ - -H "Authorization: Token $AUTH_TOKEN" \ - -d "{\"course_slug\": \"$COURSE_SLUG\"}") - -JOIN_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"token":"[^"]*' | cut -d'"' -f4) - -if [ -z "$JOIN_TOKEN" ]; then - echo "Error: Failed to get join token" - echo "Response: $TOKEN_RESPONSE" - exit 1 -fi - -echo "✓ Join token generated" -echo "" - -FULL_URL="$LIVE_BASE/?access_token=$JOIN_TOKEN" - -echo "==========================================" -echo "Room created successfully!" -echo "==========================================" -echo "" -echo "Full Room Link:" -echo "$FULL_URL" -echo "" -echo "Room Details:" -echo " Room ID: $CREATED_ROOM_ID" -echo " Course: $COURSE_SLUG" -echo "==========================================" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c32bb21..828f496 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,10 +10,10 @@ services: command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=6 --timeout 560 volumes: # - static_volume:/usr/src/app/static - - media_volume:/usr/src/app/media + - media_volume:/usr/src/app/media - staticfiles_volume:/usr/src/app/staticfiles ports: - - "8010:8000" + - "8024:8000" env_file: - .env.prod depends_on: @@ -26,7 +26,7 @@ services: postgres: container_name: imam-javad_db ports: - - "5575:5432" + - "5513:5432" restart: unless-stopped image: postgres:14.0 volumes: @@ -43,7 +43,6 @@ services: - redis_data:/data networks: - imam-javad - # imam-javad_celery: # container_name: imam-javad_celery # build: @@ -81,10 +80,10 @@ services: volumes: - staticfiles_volume: # Volume برای فایل‌های static - media_volume: - postgres_data: - # static_volume: + staticfiles_volume: # Volume برای فایل‌های static + media_volume: + postgres_data: # static_volume: + redis_data: networks: diff --git a/test.py b/test.py index eb0983d..8e10fa2 100644 --- a/test.py +++ b/test.py @@ -11,7 +11,6 @@ from django.core.wsgi import get_wsgi_application application = get_wsgi_application() -from apps.course.models import Course, CourseCategory from apps.hadis.models.category import HadisCategory g = HadisCategory.objects.all()[2] diff --git a/utils/admin.py b/utils/admin.py index 98e7552..febda9d 100644 --- a/utils/admin.py +++ b/utils/admin.py @@ -42,49 +42,39 @@ from unfold.sites import UnfoldAdminSite def is_dovoodi_panel(request): """ Returns True if the user is accessing the Dovoodi admin panel. - Checks if 'dovodi' or 'dovoodi' is in the host domain, or if '/dovoodi/' - exists anywhere in the path. + In standalone Dovoodi project, this is always True. """ - host = request.get_host() - return 'dovodi' in host or 'dovoodi' in host or '/dovoodi/' in request.path + return True def is_main_panel(request): """Returns True if the user is accessing the Main (Imam Javad) admin panel.""" - return not is_dovoodi_panel(request) + return False def admin_url_generator(request, url_name): """ - Dynamically generates admin URLs based on the current active panel. + Dynamically generates admin URLs under the standard 'admin' namespace. """ - _ = project_admin_site.urls - _ = dovoodi_admin_site.urls - - if is_dovoodi_panel(request): - namespace = 'dovoodi_admin' - else: - namespace = 'imam_javad_admin' - - full_view_name = f"{namespace}:{url_name}" - try: - return reverse(full_view_name) + return reverse(f"admin:{url_name}") except Exception: return "#" +# FIXME: DEPENDENCY ON DELETED APP (certificate) — Commented out for Dovoodi project separation def get_pending_certificates_badge(request): - """Generates the integer for the sidebar badge""" - try: - from apps.certificate.models import Certificate - qs = Certificate.objects.filter(status='pending') - - if request.user.is_authenticated and not request.user.is_staff and not getattr(request.user, 'is_superuser', False): - qs = qs.filter(course__professor=request.user) - - count = qs.count() - return count if count > 0 else None - except Exception as e: - print(f"Badge Error: {e}") - return None + """Generates the integer for the sidebar badge (Dummy for Dovoodi)""" + # try: + # from apps.certificate.models import Certificate + # qs = Certificate.objects.filter(status='pending') + # + # if request.user.is_authenticated and not request.user.is_staff and not getattr(request.user, 'is_superuser', False): + # qs = qs.filter(course__professor=request.user) + # + # count = qs.count() + # return count if count > 0 else None + # except Exception as e: + # print(f"Badge Error: {e}") + # return None + return None def variables(request): return {"plausible_domain": getattr(settings, 'PLAUSIBLE_DOMAIN', '')} @@ -377,12 +367,11 @@ class AdminSitePlaceholder(UnfoldAdminSite): self.register(model, admin_class, **options) else: model = model_or_iterable - if model in self._registry: - self._registry[model] = admin_class - else: - self._registry[model] = admin_class + self._registry[model] = admin_class if self._real_instance is not None: + if model in self._real_instance._registry: + return self._real_instance.register(model, admin_class, **options) @@ -430,18 +419,27 @@ class LazyAdminSite(UnfoldAdminSite): def register(self, model_or_iterable, admin_class=None, **options): self._ensure_instance() - return self._instance.register(model_or_iterable, admin_class, **options) + if isinstance(model_or_iterable, (list, tuple)): + models = model_or_iterable + else: + models = [model_or_iterable] + + for model in models: + if model in self._instance._registry: + continue + self._instance.register(model, admin_class, **options) -project_admin_site = LazyAdminSite(FormulaAdminSite, 'imam_javad_admin') -dovoodi_admin_site = LazyAdminSite(DovoodiAdminSite, 'dovoodi_admin') +# Unified Dovoodi admin site under the standard 'admin' namespace +dovoodi_admin_site = LazyAdminSite(DovoodiAdminSite, 'admin') +# Alias project_admin_site to dovoodi_admin_site for seamless backward compatibility +project_admin_site = dovoodi_admin_site def replace_placeholders_with_real_sites(): global project_admin_site, dovoodi_admin_site - if isinstance(project_admin_site, AdminSitePlaceholder): - project_admin_site = FormulaAdminSite(name='imam_javad_admin') if isinstance(dovoodi_admin_site, AdminSitePlaceholder): - dovoodi_admin_site = DovoodiAdminSite(name='dovoodi_admin') + dovoodi_admin_site = DovoodiAdminSite(name='admin') + project_admin_site = dovoodi_admin_site class HomeView(RedirectView): @@ -455,8 +453,6 @@ class HomeView(RedirectView): def dashboard_callback(request, context): from django.apps import apps - from django.db.models import Count, Sum - from django.utils import timezone if context is None: context = {} @@ -465,118 +461,29 @@ def dashboard_callback(request, context): "navigation": [{"title": _("Dashboard"), "link": "/", "active": True}], "kpi": [], "top_courses": [], - "tx_stats": {}, # New dict for our multi-segment donut chart + "tx_stats": {}, }) if not hasattr(request, "user") or not request.user.is_authenticated: return context - # ------------------------------------------------------------- - # 1. IMAM JAVAD PANEL STATS - # ------------------------------------------------------------- - if is_main_panel(request): - try: - StudentUser = apps.get_model('account', 'StudentUser') - ProfessorUser = apps.get_model('account', 'ProfessorUser') - Course = apps.get_model('course', 'Course') - Blog = apps.get_model('blog', 'Blog') - Certificate = apps.get_model('certificate', 'Certificate') - Transaction = apps.get_model('transaction', 'TransactionParticipant') - - # --- 1. Basic Counts --- - active_students = StudentUser.objects.filter(is_active=True).count() - active_professors = ProfessorUser.objects.filter(is_active=True).count() - active_courses = Course.objects.exclude(status='inactive').count() - total_blogs = Blog.objects.count() - - # --- 2. Certificates --- - certs_qs = Certificate.objects.filter(status='pending') - if not request.user.is_staff and not getattr(request.user, 'is_superuser', False): - certs_qs = certs_qs.filter(course__professor=request.user) - pending_certs = certs_qs.count() - - # --- 3. Revenue (Last 30 Days) --- - thirty_days_ago = timezone.now() - timezone.timedelta(days=30) - revenue_data = Transaction.objects.filter( - status='success', - created_at__gte=thirty_days_ago - ).aggregate(Sum('price')) - revenue = revenue_data['price__sum'] or 0 - - # --- 4. Transaction Multi-Status Breakdown --- - total_tx = Transaction.objects.count() - if total_tx > 0: - success_count = Transaction.objects.filter(status='success').count() - pending_count = Transaction.objects.filter(status='pending').count() - waiting_count = Transaction.objects.filter(status='waiting_approval').count() - failed_count = Transaction.objects.filter(status='failed').count() - - # Calculate percentages - pct_success = (success_count / total_tx) * 100 - pct_pending = (pending_count / total_tx) * 100 - pct_waiting = (waiting_count / total_tx) * 100 - pct_failed = (failed_count / total_tx) * 100 - - # Calculate SVG Dash Offsets (Accumulative for overlapping circles) - offset_success = 100 - pct_success - offset_pending = 100 - (pct_success + pct_pending) - offset_waiting = 100 - (pct_success + pct_pending + pct_waiting) - offset_failed = 100 - (pct_success + pct_pending + pct_waiting + pct_failed) # Should be 0 - else: - pct_success = pct_pending = pct_waiting = pct_failed = 0 - offset_success = offset_pending = offset_waiting = offset_failed = 100 - - context["tx_stats"] = { - "total": total_tx, - "pct_success": round(pct_success, 1), - "pct_pending": round(pct_pending, 1), - "pct_waiting": round(pct_waiting, 1), - "pct_failed": round(pct_failed, 1), - # Format as strings to prevent Django from converting dots to commas in Russian - "offset_success": f"{offset_success:.2f}", - "offset_pending": f"{offset_pending:.2f}", - "offset_waiting": f"{offset_waiting:.2f}", - } + # DOVOODI PANEL STATS ONLY + try: + Video = apps.get_model('video', 'Video') + Book = apps.get_model('library', 'Book') + Article = apps.get_model('article', 'Article') + Hadis = apps.get_model('hadis', 'Hadis') + Podcast = apps.get_model('podcast', 'Podcast') + + total_multimedia = Video.objects.count() + Podcast.objects.count() + total_reading = Book.objects.count() + Article.objects.count() - # --- 5. Top 5 Courses --- - top_courses = Course.objects.select_related('professor').annotate( - participant_count=Count('participants') - ).order_by('-participant_count')[:5] - - context["top_courses"] = top_courses - - # --- Map to KPIs --- - context["kpi"] = [ - {"title": _("Active Students"), "metric": f"{active_students:,}"}, - {"title": _("Professors"), "metric": f"{active_professors:,}"}, - {"title": _("Active Courses"), "metric": f"{active_courses:,}"}, - {"title": _("Total Blogs"), "metric": f"{total_blogs:,}"}, - {"title": _("30-Day Revenue"), "metric": f"${revenue:,.2f}", "footer": format_html('+ {}', _("Updated Today"))}, - {"title": _("Pending Certificates"), "metric": f"{pending_certs:,}", "footer": format_html('{}', _("Requires Action")) if pending_certs > 0 else ""}, - ] - except Exception as e: - print(f"Dashboard KPI Error (Main Panel): {e}") - - # ------------------------------------------------------------- - # 2. DOVOODI PANEL STATS - # ------------------------------------------------------------- - else: - try: - Video = apps.get_model('video', 'Video') - Book = apps.get_model('library', 'Book') - Article = apps.get_model('article', 'Article') - Hadis = apps.get_model('hadis', 'Hadis') - Podcast = apps.get_model('podcast', 'Podcast') - - total_multimedia = Video.objects.count() + Podcast.objects.count() - total_reading = Book.objects.count() + Article.objects.count() - - context["kpi"] = [ - {"title": _("Hadith Database"), "metric": f"{Hadis.objects.count():,}"}, - {"title": _("Books & Articles"), "metric": f"{total_reading:,}"}, - {"title": _("Multimedia"), "metric": f"{total_multimedia:,}"}, - ] - except Exception as e: - print(f"Dashboard KPI Error (Dovoodi Panel): {e}") + context["kpi"] = [ + {"title": _("Hadith Database"), "metric": f"{Hadis.objects.count():,}"}, + {"title": _("Books & Articles"), "metric": f"{total_reading:,}"}, + {"title": _("Multimedia"), "metric": f"{total_multimedia:,}"}, + ] + except Exception as e: + print(f"Dashboard KPI Error (Dovoodi Panel): {e}") return context \ No newline at end of file