513 changed files with 76574 additions and 1157 deletions
-
88.dockerignore
-
8.env.prod
-
83.zencoder/rules/repo.md
-
132BUGFIX_REPORT.md
-
80CLAUDE.md
-
5Dockerfile.prod
-
134OPTIMIZATION_PLAN.md
-
180PODCAST_REFACTORING_SUMMARY.md
-
350PODCAST_SETUP_GUIDE.md
-
328README_WEBHOOK.md
-
164VIDEO_REFACTORING_SUMMARY.md
-
607adjustemnts.md
-
70apps/account/admin/__init__.py
-
59apps/account/admin/location.py
-
12apps/account/admin/notification.py
-
76apps/account/admin/professor.py
-
38apps/account/admin/student.py
-
381apps/account/admin/user.py
-
28apps/account/management/commands/assign_professor_slugs.py
-
158apps/account/management/commands/migrate_user_roles.py
-
19apps/account/manager.py
-
114apps/account/middleware/admin_access.py
-
59apps/account/migrations/0001_initial.py
-
20apps/account/migrations/0002_alter_user_phone_number.py
-
30apps/account/migrations/0003_locationhistory.py
-
18apps/account/migrations/0004_alter_user_avatar.py
-
17apps/account/migrations/0005_alter_user_unique_together.py
-
45apps/account/migrations/0006_auto_20251006_1101.py
-
17apps/account/migrations/0007_user_user_agent.py
-
22apps/account/migrations/0008_loginhistory_device_os_loginhistory_user_agent.py
-
17apps/account/migrations/0009_user_client_ip.py
-
21apps/account/migrations/0010_alter_user_device_os.py
-
197apps/account/models/user.py
-
5apps/account/serializers/__init__.py
-
11apps/account/serializers/auth.py
-
37apps/account/serializers/location_history.py
-
138apps/account/serializers/user.py
-
29apps/account/serializers/user_web.py
-
101apps/account/tasks.py
-
40apps/account/templates/account/group_help_text.html
-
800apps/account/templates/account/json_editor_field.html
-
33apps/account/templates/account/user_list_section.html
-
240apps/account/tests/test_multiple_roles.py
-
10apps/account/urls.py
-
6apps/account/views/__init__.py
-
126apps/account/views/auth.py
-
358apps/account/views/location_history.py
-
60apps/account/views/notification.py
-
225apps/account/views/user.py
-
108apps/api/admin.py
-
42apps/api/decorators.py
-
31apps/api/migrations/0001_initial.py
-
42apps/api/migrations/0002_auto_20250911_1217.py
-
0apps/api/migrations/__init__.py
-
137apps/api/models.py
-
60apps/api/permissions.py
-
54apps/api/serializers.py
-
7apps/api/urls.py
-
44apps/api/views.py
-
16apps/api/views/__init__.py
-
100apps/api/views/api_views.py
-
1061apps/api/views/documentation.py
-
83apps/api/views/swagger_views.py
-
0apps/article/__init__.py
-
314apps/article/admin.py
-
6apps/article/apps.py
-
0apps/article/management/__init__.py
-
0apps/article/management/commands/__init__.py
-
445apps/article/management/commands/seed_article_data.py
-
161apps/article/migrations/0001_initial.py
-
18apps/article/migrations/0002_article_download_count.py
-
47apps/article/migrations/0003_alter_middlearticlecollection_options_and_more.py
-
0apps/article/migrations/__init__.py
-
235apps/article/models.py
-
147apps/article/serializers.py
-
4apps/article/templates/article/change_form_before_template.html
-
3apps/article/tests.py
-
17apps/article/urls.py
-
237apps/article/views.py
-
0apps/blog/__init__.py
-
119apps/blog/admin.py
-
7apps/blog/apps.py
-
367apps/blog/management/commands/seed_blog_data.py
-
53apps/blog/migrations/0001_initial.py
-
31apps/blog/migrations/0002_blogseo.py
-
39apps/blog/migrations/0003_convert_varchar_to_jsonb.py
-
0apps/blog/migrations/__init__.py
-
200apps/blog/models.py
-
142apps/blog/serializers.py
-
3apps/blog/tests.py
-
24apps/blog/urls.py
-
181apps/blog/views.py
-
0apps/bookmark/__init__.py
-
143apps/bookmark/admin.py
-
6apps/bookmark/apps.py
-
34apps/bookmark/migrations/0001_initial.py
-
35apps/bookmark/migrations/0002_rate.py
-
18apps/bookmark/migrations/0003_add_article_service_choice.py
-
23apps/bookmark/migrations/0004_auto_20251130_1758.py
-
23apps/bookmark/migrations/0005_auto_20251202_1245.py
@ -0,0 +1,88 @@ |
|||||
|
# Git |
||||
|
.git |
||||
|
.gitignore |
||||
|
.gitattributes |
||||
|
|
||||
|
# Python |
||||
|
__pycache__ |
||||
|
*.py[cod] |
||||
|
*$py.class |
||||
|
*.so |
||||
|
.Python |
||||
|
*.egg-info |
||||
|
dist/ |
||||
|
build/ |
||||
|
*.egg |
||||
|
|
||||
|
# Virtual environments |
||||
|
venv/ |
||||
|
env/ |
||||
|
ENV/ |
||||
|
.venv |
||||
|
|
||||
|
# IDEs |
||||
|
.vscode/ |
||||
|
.idea/ |
||||
|
*.swp |
||||
|
*.swo |
||||
|
*~ |
||||
|
|
||||
|
# OS |
||||
|
.DS_Store |
||||
|
Thumbs.db |
||||
|
|
||||
|
# Docker |
||||
|
docker-compose*.yml |
||||
|
Dockerfile* |
||||
|
.dockerignore |
||||
|
|
||||
|
# Development files |
||||
|
.env.dev |
||||
|
*.log |
||||
|
logs/ |
||||
|
*.sqlite3 |
||||
|
|
||||
|
# Documentation |
||||
|
*.md |
||||
|
docs/ |
||||
|
ADMIN_*.md |
||||
|
CONFIG_*.md |
||||
|
FINAL_*.md |
||||
|
FIX_*.md |
||||
|
MEDIA_*.md |
||||
|
OPTIMIZATION_*.md |
||||
|
PODCAST_*.md |
||||
|
QUICK_*.md |
||||
|
README*.md |
||||
|
VIDEO_*.md |
||||
|
BUGFIX_*.md |
||||
|
CHANGELOG_*.md |
||||
|
COLOR_*.md |
||||
|
|
||||
|
# Test files |
||||
|
test_*.py |
||||
|
test_*.sh |
||||
|
tests/ |
||||
|
.pytest_cache/ |
||||
|
|
||||
|
# Volumes |
||||
|
volumes/ |
||||
|
staticfiles/ |
||||
|
media/ |
||||
|
|
||||
|
# Scripts |
||||
|
scripts/ |
||||
|
seeds/ |
||||
|
|
||||
|
# Config |
||||
|
nginx*.conf |
||||
|
Jenkinsfile |
||||
|
.letta/ |
||||
|
.claude/ |
||||
|
.qodo/ |
||||
|
.zencoder/ |
||||
|
|
||||
|
# Misc |
||||
|
*.xmind |
||||
|
*.html |
||||
|
sshs |
||||
@ -0,0 +1,83 @@ |
|||||
|
--- |
||||
|
description: Repository Information Overview |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
|
||||
|
# Imam Javad Backend Information |
||||
|
|
||||
|
## Summary |
||||
|
A Django-based backend application for the Imam Javad platform, providing API services for various features including user accounts, courses, library resources, hadis (religious texts), videos, podcasts, and more. The application is multilingual, supporting English, Persian, and Russian. |
||||
|
|
||||
|
## Structure |
||||
|
- **apps/**: Contains all application modules (account, course, hadis, library, etc.) |
||||
|
- **config/**: Django project configuration and settings |
||||
|
- **dynamic_preferences/**: Custom preferences management system |
||||
|
- **static/**: Static files (CSS, images, media) |
||||
|
- **templates/**: HTML templates for admin and frontend views |
||||
|
- **utils/**: Utility functions and helper classes |
||||
|
- **locale/**: Translation files for multilingual support |
||||
|
|
||||
|
## Language & Runtime |
||||
|
**Language**: Python |
||||
|
**Version**: 3.9 (as specified in Dockerfile) |
||||
|
**Framework**: Django 4.2+ |
||||
|
**Build System**: pip |
||||
|
**Package Manager**: pip |
||||
|
|
||||
|
## Dependencies |
||||
|
**Main Dependencies**: |
||||
|
- Django 4.2+ |
||||
|
- Django REST Framework 3.16.0 |
||||
|
- Celery 5.2.1 |
||||
|
- PostgreSQL (psycopg2-binary 2.9.9) |
||||
|
- Redis 4.3.4 |
||||
|
- django-unfold 0.54.0 (Admin UI) |
||||
|
- django-filer 3.3.1 |
||||
|
- django-dynamic-preferences 1.16.0 |
||||
|
- django-rosetta 0.9.6 (Translations) |
||||
|
|
||||
|
**Development Dependencies**: |
||||
|
- django-debug-toolbar 4.3.0 |
||||
|
- django-reset-migrations 0.4.0 |
||||
|
|
||||
|
## Build & Installation |
||||
|
```bash |
||||
|
# Install dependencies |
||||
|
pip install -r requirements.txt |
||||
|
|
||||
|
# Run migrations |
||||
|
python manage.py migrate |
||||
|
|
||||
|
# Run development server |
||||
|
python manage.py runserver 0.0.0.0:8000 |
||||
|
``` |
||||
|
|
||||
|
## Docker |
||||
|
**Dockerfile**: Dockerfile (development), Dockerfile.prod (production) |
||||
|
**Image**: Python 3.9 |
||||
|
**Configuration**: Docker Compose with PostgreSQL database |
||||
|
**Run Command**: |
||||
|
```bash |
||||
|
docker-compose up -d |
||||
|
``` |
||||
|
|
||||
|
## Testing |
||||
|
**Framework**: Django Test |
||||
|
**Test Location**: Each app has a tests.py file |
||||
|
**Run Command**: |
||||
|
```bash |
||||
|
python manage.py test |
||||
|
``` |
||||
|
|
||||
|
## Main Components |
||||
|
- **Account**: User authentication and profile management |
||||
|
- **Course**: Online course management system |
||||
|
- **Hadis**: Religious text management and API |
||||
|
- **Library**: Digital book library and collections |
||||
|
- **Video**: Video content management |
||||
|
- **Podcast**: Audio content management |
||||
|
- **Chat**: Messaging functionality |
||||
|
- **Quiz**: Quiz and assessment system |
||||
|
- **Transaction**: Payment processing |
||||
|
- **Certificate**: Course completion certificates |
||||
|
- **API**: Core API endpoints and documentation |
||||
@ -0,0 +1,132 @@ |
|||||
|
# 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 |
||||
@ -0,0 +1,80 @@ |
|||||
|
# CodeViz Research Context |
||||
|
|
||||
|
> **Note**: This file contains research context from CodeViz. Most recent contexts are at the bottom. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Research Query |
||||
|
|
||||
|
در مورد چت میشه بگی |
||||
|
|
||||
|
*Session: 169492aff6d1e2bbd34a3c87fd82786e | Generated: 7/22/2025, 4:26:02 PM* |
||||
|
|
||||
|
### Analysis Summary |
||||
|
|
||||
|
# Chat Functionality Overview |
||||
|
|
||||
|
The chat functionality in this codebase is primarily handled by the **`chat`** application, located at [apps/chat/](apps/chat/). This application is responsible for managing chat-related data models, administrative interfaces, and potentially views for handling chat interactions. |
||||
|
|
||||
|
## High-Level Architecture |
||||
|
|
||||
|
The **`chat`** application is a self-contained Django application designed to manage real-time or asynchronous chat features. It integrates with the main project through its models, views, and potentially URL configurations. |
||||
|
|
||||
|
## Mid-Level Components |
||||
|
|
||||
|
The **`chat`** application consists of several key components: |
||||
|
|
||||
|
### **`apps.py`** - Application Configuration |
||||
|
This file defines the configuration for the chat application, including its name. |
||||
|
- **Purpose**: Configures the Django application. |
||||
|
- **Internal Parts**: Contains the `ChatConfig` class. |
||||
|
- **External Relationships**: Registered in the project's settings to enable the `chat` application. |
||||
|
- [ChatConfig](apps/chat/apps.py) |
||||
|
|
||||
|
### **`models.py`** - Data Models |
||||
|
This file defines the database models for chat-related entities. These models represent the structure of the data stored in the database for chat messages, conversations, or related concepts. |
||||
|
- **Purpose**: Defines the data structures for chat entities. |
||||
|
- **Internal Parts**: Contains Django model classes (e.g., `Message`, `Conversation`, `ChatRoom`, etc., depending on implementation). |
||||
|
- **External Relationships**: Interacts with the database to store and retrieve chat data. These models are likely related to user models from the `account` app. |
||||
|
- [models.py](apps/chat/models.py) |
||||
|
|
||||
|
### **`admin.py`** - Admin Interface |
||||
|
This file registers the chat models with the Django administrative interface, allowing superusers to manage chat data directly from the admin panel. |
||||
|
- **Purpose**: Provides an administrative interface for managing chat data. |
||||
|
- **Internal Parts**: Contains `admin.site.register()` calls for chat models. |
||||
|
- **External Relationships**: Integrates with Django's built-in admin site. |
||||
|
- [admin.py](apps/chat/admin.py) |
||||
|
|
||||
|
### **`views.py`** - Views and Logic |
||||
|
This file would contain the logic for handling requests related to chat, such as sending messages, retrieving chat history, or managing chat rooms. |
||||
|
- **Purpose**: Implements the business logic and presentation for chat features. |
||||
|
- **Internal Parts**: Contains Django views (function-based or class-based) that handle HTTP requests. |
||||
|
- **External Relationships**: Interacts with the models to perform CRUD operations on chat data and potentially renders templates or returns API responses. |
||||
|
- [views.py](apps/chat/views.py) |
||||
|
|
||||
|
### **`migrations/`** - Database Migrations |
||||
|
This directory contains the database migration files generated by Django, which manage changes to the chat application's database schema. |
||||
|
- **Purpose**: Manages database schema evolution for chat models. |
||||
|
- **Internal Parts**: Contains numbered migration files (e.g., `0001_initial.py`). |
||||
|
- **External Relationships**: Applied by Django's ORM to update the database schema. |
||||
|
- [migrations/](apps/chat/migrations/) |
||||
|
|
||||
|
### **`management/`** - Custom Management Commands |
||||
|
This directory might contain custom Django management commands specific to the chat application, such as commands for cleaning up old messages or seeding chat data. |
||||
|
- **Purpose**: Provides custom command-line utilities for chat-related tasks. |
||||
|
- **Internal Parts**: Contains Python files defining custom commands. |
||||
|
- **External Relationships**: Executed via `python manage.py <command_name>`. |
||||
|
- [management/](apps/chat/management/) |
||||
|
|
||||
|
### **`templates/`** - HTML Templates |
||||
|
This directory would hold any HTML templates used by the chat application's views for rendering web pages. |
||||
|
- **Purpose**: Stores HTML templates for rendering chat-related user interfaces. |
||||
|
- **Internal Parts**: Contains `.html` files. |
||||
|
- **External Relationships**: Used by Django views to render dynamic content. |
||||
|
- [templates/](apps/chat/templates/) |
||||
|
|
||||
|
## Integration with Project URLs |
||||
|
|
||||
|
The chat application's URLs are likely included in the main project's URL configuration, typically found in [config/urls.py](config/urls.py). This file acts as the central routing mechanism for the entire application, directing requests to the appropriate views within the `chat` app or other applications. |
||||
|
- [urls.py](config/urls.py) |
||||
|
|
||||
@ -0,0 +1,134 @@ |
|||||
|
# 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 |
||||
@ -0,0 +1,180 @@ |
|||||
|
# Podcast System Refactoring Summary |
||||
|
|
||||
|
## Overview |
||||
|
تغییرات اساسی در معماری سیستم podcast برای همسانسازی با ساختار Video |
||||
|
|
||||
|
## Changes Made |
||||
|
|
||||
|
### 1. Model Changes |
||||
|
|
||||
|
#### ❌ Removed: |
||||
|
- **PodcastInCollection** model (مدل منسوخ شده که پادکستها را مستقیماً به Collection ها متصل میکرد) |
||||
|
- **podcasts** field from PodcastCollection (فیلد ManyToMany که پادکستها را به Collection متصل میکرد) |
||||
|
- **collections** field from Podcast (فیلد ManyToMany که پادکستها را به Collection متصل میکرد) |
||||
|
|
||||
|
#### ✅ Added/Enhanced: |
||||
|
- **PodcastPlaylistInCollection** model (معماری صحیح که Playlist ها را به Collection متصل میکند) |
||||
|
- **PodcastPlaylist** enhanced with full fields: |
||||
|
- slug, slogan, description, thumbnail |
||||
|
- categories (ManyToMany to PodcastCategory) |
||||
|
- collections (ManyToMany through PodcastPlaylistInCollection) |
||||
|
- order, status, view_count, total_time |
||||
|
- increment_view_count() and calculate_total_time() methods |
||||
|
|
||||
|
### 2. Architecture Improvement |
||||
|
|
||||
|
**قبل:** |
||||
|
``` |
||||
|
PodcastCollection --[PodcastInCollection]--> Podcast |
||||
|
``` |
||||
|
|
||||
|
**بعد:** |
||||
|
``` |
||||
|
PodcastCollection --[PodcastPlaylistInCollection]--> PodcastPlaylist --[PlaylistItem]--> Podcast |
||||
|
``` |
||||
|
|
||||
|
این تغییر باعث میشود: |
||||
|
- Collection ها شامل Playlist باشند (نه مستقیماً Podcast) |
||||
|
- سازماندهی بهتر محتوا |
||||
|
- معماری منطقیتر و قابل نگهداریتر |
||||
|
- همسان با ساختار Video |
||||
|
|
||||
|
### 3. Admin Panel Updates |
||||
|
|
||||
|
**apps/podcast/admin.py:** |
||||
|
- تغییر `PodcastInCollectionInline` به `PodcastPlaylistInCollectionInlineForCollection` |
||||
|
- اضافه شدن `PodcastPlaylistInCollectionInline` برای PodcastPlaylist admin |
||||
|
- تغییر `count_podcasts()` به `count_playlists()` در Collection و Category admins |
||||
|
- اضافه شدن فیلدهای جدید به PodcastPlaylistAdmin |
||||
|
- محاسبه خودکار total_time در save_model |
||||
|
|
||||
|
### 4. Serializers Updates |
||||
|
|
||||
|
**apps/podcast/serializers.py:** |
||||
|
- تغییر `podcast_count` به `playlist_count` در PodcastCategoryListSerializer |
||||
|
- اضافه شدن **PodcastPlaylistListSerializer** جدید |
||||
|
- اضافه شدن **PodcastPlaylistDetailSerializer** جدید با: |
||||
|
- categories, thumbnail, bookmark |
||||
|
- user_rate, average_rate |
||||
|
- podcasts (لیست پادکستهای درون playlist) |
||||
|
- total_time_formatted |
||||
|
- تغییر MiddlePodcastCollectionSerializer برای استفاده از playlists |
||||
|
|
||||
|
### 5. Migration |
||||
|
|
||||
|
**Migration File:** `0003_refactor_podcast_models.py` |
||||
|
- ایجاد PodcastPlaylistInCollection |
||||
|
- حذف فیلد collections از podcast |
||||
|
- حذف فیلد podcasts از podcastcollection |
||||
|
- اضافه فیلدهای جدید به podcastplaylist |
||||
|
- حذف مدل PodcastInCollection |
||||
|
|
||||
|
### 6. Management Commands |
||||
|
|
||||
|
#### cleanup_podcast_data.py |
||||
|
حذف تمام دادههای PodcastCategory، PodcastCollection، و PodcastPlaylist (بدون حذف Podcast) |
||||
|
|
||||
|
**Usage:** |
||||
|
```bash |
||||
|
python manage.py cleanup_podcast_data --confirm |
||||
|
``` |
||||
|
|
||||
|
#### create_podcast_playlists.py |
||||
|
ایجاد 10 پلیلیست با محتوای روسی درباره پیامبران و امامان |
||||
|
|
||||
|
**Usage:** |
||||
|
```bash |
||||
|
python manage.py create_podcast_playlists |
||||
|
python manage.py create_podcast_playlists --dry-run # for testing |
||||
|
``` |
||||
|
|
||||
|
**Playlists:** |
||||
|
1. Лекции о Пророке Мухаммаде (да благословит его Аллах) |
||||
|
2. Истории пророков в аудио формате |
||||
|
3. Имам Али: Аудио наставления |
||||
|
4. Имам Хусейн: Аудио о Кербеле |
||||
|
5. Двенадцать Имамов: Аудио курс |
||||
|
6. Фатима аз-Захра: Аудио лекции |
||||
|
7. Имам Махди: Аудио о ожидании |
||||
|
8. Чудеса пророков: Аудио рассказы |
||||
|
9. Нравственность Ахль аль-Байт: Аудио |
||||
|
10. Имам Риза: Аудио наследие |
||||
|
|
||||
|
### 7. API Changes |
||||
|
|
||||
|
**URLs to add/update** (similar to video app): |
||||
|
```python |
||||
|
# Suggested new endpoints |
||||
|
path('playlists/', PodcastPlaylistListAPIView.as_view(), name='playlist-list'), |
||||
|
path('playlists/<slug:slug>/', PodcastPlaylistDetailAPIView.as_view(), name='playlist-detail'), |
||||
|
``` |
||||
|
|
||||
|
**Expected API responses:** |
||||
|
|
||||
|
**GET /api/podcast/playlists/** |
||||
|
- Filter by: category, collection, is_bookmark, search |
||||
|
- Returns: List of playlists with thumbnail, slogan, view_count, total_time |
||||
|
|
||||
|
**GET /api/podcast/playlists/<slug>/** |
||||
|
- Returns: Full playlist details with podcasts, categories, ratings, bookmarks |
||||
|
|
||||
|
**GET /api/podcast/collections/** |
||||
|
- Returns: Collections with playlists (not direct podcasts) |
||||
|
|
||||
|
### 8. Next Steps |
||||
|
|
||||
|
1. ✅ Models refactored |
||||
|
2. ✅ Admin panel updated |
||||
|
3. ✅ Serializers updated |
||||
|
4. ✅ Migration created (needs to be applied when DB is available) |
||||
|
5. ✅ Management commands created |
||||
|
6. 🔄 Views need to be updated (add PodcastPlaylistListAPIView and PodcastPlaylistDetailAPIView) |
||||
|
7. 🔄 URLs need to be updated |
||||
|
8. 🔄 Documentation needs to be updated |
||||
|
9. 🔄 Test when database is available |
||||
|
|
||||
|
## Important Notes |
||||
|
|
||||
|
- ⚠️ PodcastInCollection model is completely removed |
||||
|
- ✅ Podcasts are preserved - no podcast data was lost |
||||
|
- ✅ New architecture matches Video app structure |
||||
|
- ✅ Admin panel updated to reflect new structure |
||||
|
- 🔄 API endpoints need minor updates for playlist support |
||||
|
- 🔄 Migration will run when database connection is restored |
||||
|
|
||||
|
## Commands for Testing (when DB is available) |
||||
|
|
||||
|
```bash |
||||
|
# Apply migration |
||||
|
python manage.py migrate podcast |
||||
|
|
||||
|
# Clean up old data |
||||
|
python manage.py cleanup_podcast_data --confirm |
||||
|
|
||||
|
# Create 10 playlists with all podcasts |
||||
|
python manage.py create_podcast_playlists |
||||
|
|
||||
|
# Check current state |
||||
|
python manage.py shell -c " |
||||
|
from apps.podcast.models import Podcast, PodcastPlaylist, PodcastCollection, PodcastCategory |
||||
|
print(f'Podcasts: {Podcast.objects.count()}') |
||||
|
print(f'Playlists: {PodcastPlaylist.objects.count()}') |
||||
|
print(f'Collections: {PodcastCollection.objects.count()}') |
||||
|
print(f'Categories: {PodcastCategory.objects.count()}') |
||||
|
" |
||||
|
``` |
||||
|
|
||||
|
## Comparison with Video App |
||||
|
|
||||
|
تمام تغییرات مشابه با آنچه برای اپ video انجام شد: |
||||
|
|
||||
|
| Feature | Video App | Podcast App | |
||||
|
|---------|-----------|-------------| |
||||
|
| Playlist Model | VideoPlaylist | PodcastPlaylist ✅ | |
||||
|
| Playlist-Collection Link | VideoPlaylistInCollection | PodcastPlaylistInCollection ✅ | |
||||
|
| Item Model | PlaylistItem | PlaylistItem ✅ | |
||||
|
| Remove Direct Link | VideoInCollection removed | PodcastInCollection removed ✅ | |
||||
|
| Admin Integration | Complete | Complete ✅ | |
||||
|
| Serializers | Complete | Complete ✅ | |
||||
|
| Management Commands | cleanup + create | cleanup + create ✅ | |
||||
|
| Documentation | Updated | Need to update 🔄 | |
||||
@ -0,0 +1,350 @@ |
|||||
|
# راهنمای راهاندازی کامل سیستم Podcast |
||||
|
|
||||
|
## مراحل به ترتیب اجرا |
||||
|
|
||||
|
### 1️⃣ اعمال Migration |
||||
|
|
||||
|
ابتدا باید migration های podcast را اجرا کنید: |
||||
|
|
||||
|
```bash |
||||
|
python manage.py migrate podcast |
||||
|
``` |
||||
|
|
||||
|
**خروجی مورد انتظار:** |
||||
|
``` |
||||
|
Operations to perform: |
||||
|
Apply all migrations: podcast |
||||
|
Running migrations: |
||||
|
Applying podcast.0003_refactor_podcast_models... OK |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 2️⃣ پاکسازی دادههای قدیمی (اختیاری) |
||||
|
|
||||
|
اگر دادههای قدیمی پادکست دارید، ابتدا آنها را پاک کنید: |
||||
|
|
||||
|
```bash |
||||
|
python manage.py cleanup_podcast_data --confirm |
||||
|
``` |
||||
|
|
||||
|
**این دستور چه کاری انجام میدهد:** |
||||
|
- ✅ تمام PodcastCategory ها را حذف میکند |
||||
|
- ✅ تمام PodcastCollection ها را حذف میکند |
||||
|
- ✅ تمام PodcastPlaylist ها را حذف میکند |
||||
|
- ✅ تمام PlaylistItem ها را حذف میکند |
||||
|
- ✅ **Podcast ها را حذف نمیکند** (دادههای اصلی حفظ میشوند) |
||||
|
|
||||
|
**خروجی نمونه:** |
||||
|
``` |
||||
|
=== Current Data Count === |
||||
|
PodcastCategory: 3 |
||||
|
PodcastCollection: 4 |
||||
|
PodcastPlaylist: 2 |
||||
|
PlaylistItem: 12 |
||||
|
|
||||
|
=== Podcast Data Will NOT Be Deleted === |
||||
|
✓ Deleted 12 PlaylistItems |
||||
|
✓ Deleted 2 PodcastPlaylists |
||||
|
✓ Deleted 4 PodcastCollections |
||||
|
✓ Deleted 3 PodcastCategories |
||||
|
|
||||
|
✓ All data deleted successfully! |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 3️⃣ ایجاد دستهبندیهای پادکست |
||||
|
|
||||
|
ابتدا باید 8 دستهبندی برای پادکستها ایجاد کنید: |
||||
|
|
||||
|
```bash |
||||
|
python manage.py create_podcast_categories |
||||
|
``` |
||||
|
|
||||
|
**این دستور چه کاری انجام میدهد:** |
||||
|
- ✅ 8 دستهبندی با عناوین روسی ایجاد میکند |
||||
|
- ✅ ترتیب (order) برای هر دسته تعیین میکند |
||||
|
- ✅ همه را فعال (status=True) میکند |
||||
|
|
||||
|
**دستهبندیهای ایجاد شده:** |
||||
|
1. Пророки и посланники (پیامبران و فرستادگان) |
||||
|
2. Имамы Ахль аль-Байт (امامان اهلبیت) |
||||
|
3. Коранические истории (داستانهای قرآنی) |
||||
|
4. Исламская философия (فلسفه اسلامی) |
||||
|
5. Нравственность и этика (اخلاق و آداب) |
||||
|
6. История ислама (تاریخ اسلام) |
||||
|
7. Кербела и Ашура (کربلا و عاشورا) |
||||
|
8. Духовное развитие (رشد معنوی) |
||||
|
|
||||
|
**خروجی نمونه:** |
||||
|
``` |
||||
|
✓ Created category: Пророки и посланники |
||||
|
✓ Created category: Имамы Ахль аль-Байт |
||||
|
... |
||||
|
✓ Successfully created 8 categories! |
||||
|
``` |
||||
|
|
||||
|
**گزینههای اضافی:** |
||||
|
```bash |
||||
|
# پاک کردن دستهبندیهای قبلی و ایجاد مجدد |
||||
|
python manage.py create_podcast_categories --clean |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 4️⃣ تبدیل ویدیوها به پادکست |
||||
|
|
||||
|
این مرحله **مهمترین مرحله** است. ویدیوها را به پادکست (صدا) تبدیل میکند: |
||||
|
|
||||
|
```bash |
||||
|
python manage.py convert_videos_to_podcasts |
||||
|
``` |
||||
|
|
||||
|
**این دستور چه کاری انجام میدهد:** |
||||
|
- 🎥 تمام ویدیوهای موجود را پیدا میکند |
||||
|
- 🎵 با ffmpeg صدای هر ویدیو را استخراج میکند (به فرمت MP3) |
||||
|
- 🖼️ Thumbnail ویدیو را کپی میکند |
||||
|
- 📝 Title و Description را کپی میکند |
||||
|
- ⏱️ مدت زمان (duration) را کپی میکند |
||||
|
- 💾 همه را به عنوان Podcast ذخیره میکند |
||||
|
|
||||
|
**گزینههای اضافی:** |
||||
|
|
||||
|
```bash |
||||
|
# فقط 5 ویدیو اول را تبدیل کن (برای تست) |
||||
|
python manage.py convert_videos_to_podcasts --limit 5 |
||||
|
|
||||
|
# پادکستهای موجود را نادیده بگیر |
||||
|
python manage.py convert_videos_to_podcasts --skip-existing |
||||
|
|
||||
|
# حالت آزمایشی (هیچ چیز تغییر نمیکند، فقط نمایش میدهد) |
||||
|
python manage.py convert_videos_to_podcasts --dry-run |
||||
|
``` |
||||
|
|
||||
|
**خروجی نمونه:** |
||||
|
``` |
||||
|
Found 31 videos to convert |
||||
|
This process will take time as it extracts audio from each video... |
||||
|
|
||||
|
Processing: Жизнь Пророка Мухаммада |
||||
|
Extracting audio... |
||||
|
Running ffmpeg... |
||||
|
✓ Audio extracted: 45.23 MB |
||||
|
✓ Thumbnail copied |
||||
|
✓ Saved podcast: Жизнь Пророка Мухаммада (slug: zhizn-proroka-mukhammada) |
||||
|
|
||||
|
Processing: Истории пророков в Коране |
||||
|
Extracting audio... |
||||
|
Running ffmpeg... |
||||
|
✓ Audio extracted: 38.67 MB |
||||
|
✓ Thumbnail copied |
||||
|
✓ Saved podcast: Истории пророков в Коране (slug: istorii-prorokov-v-korane) |
||||
|
|
||||
|
... |
||||
|
|
||||
|
✓ Conversion complete! |
||||
|
Processed: 31 |
||||
|
Skipped: 0 |
||||
|
Failed: 0 |
||||
|
``` |
||||
|
|
||||
|
**⚠️ نکات مهم:** |
||||
|
- این فرآیند **زمانبر** است (بسته به تعداد و حجم ویدیوها) |
||||
|
- نیاز به **ffmpeg** نصب شده دارد (قبلاً نصب شده است) |
||||
|
- فضای **دیسک کافی** برای فایلهای صوتی لازم است |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 5️⃣ ایجاد 10 پلیلیست پادکست |
||||
|
|
||||
|
حالا پادکستها را در 10 پلیلیست سازماندهی میکنیم: |
||||
|
|
||||
|
```bash |
||||
|
python manage.py create_podcast_playlists |
||||
|
``` |
||||
|
|
||||
|
**این دستور چه کاری انجام میدهد:** |
||||
|
- 📚 10 پلیلیست با عناوین روسی درباره پیامبران و امامان ایجاد میکند |
||||
|
- 🎵 تمام پادکستهای موجود را به هر پلیلیست اضافه میکند |
||||
|
- ⏱️ مدت زمان کل هر پلیلیست را محاسبه میکند |
||||
|
- 💾 همه را ذخیره میکند |
||||
|
|
||||
|
**پلیلیستهای ایجاد شده:** |
||||
|
1. Лекции о Пророке Мухаммаде (لکچرهای صوتی درباره پیامبر محمد) |
||||
|
2. Истории пророков в аудио формате (داستانهای پیامبران به صورت صوتی) |
||||
|
3. Имам Али: Аудио наставления (امام علی: راهنماییهای صوتی) |
||||
|
4. Имам Хусейн: Аудио о Кербеле (امام حسین: صوتی درباره کربلا) |
||||
|
5. Двенадцать Имамов: Аудио курс (دوازده امام: دوره صوتی) |
||||
|
6. Фатима аз-Захра: Аудио лекции (فاطمه زهرا: لکچرهای صوتی) |
||||
|
7. Имам Махди: Аудио о ожидании (امام مهدی: صوتی درباره انتظار) |
||||
|
8. Чудеса пророков: Аудио рассказы (معجزات پیامبران: داستانهای صوتی) |
||||
|
9. Нравственность Ахль аль-Байт: Аудио (اخلاق اهلبیت: صوتی) |
||||
|
10. Имам Риза: Аудио наследие (امام رضا: میراث صوتی) |
||||
|
|
||||
|
**خروجی نمونه:** |
||||
|
``` |
||||
|
Found 31 podcasts in database |
||||
|
Creating 10 playlists... |
||||
|
|
||||
|
✓ Created playlist: Лекции о Пророке Мухаммаде |
||||
|
Added 31 podcasts to playlist |
||||
|
Total duration: 1 day, 22:33:23 |
||||
|
|
||||
|
✓ Created playlist: Истории пророков в аудио формате |
||||
|
Added 31 podcasts to playlist |
||||
|
Total duration: 1 day, 22:33:23 |
||||
|
|
||||
|
... |
||||
|
|
||||
|
✓ Successfully created 10 playlists! |
||||
|
✓ Each playlist contains all 31 podcasts |
||||
|
``` |
||||
|
|
||||
|
**گزینههای اضافی:** |
||||
|
|
||||
|
```bash |
||||
|
# حالت آزمایشی (بدون ایجاد واقعی) |
||||
|
python manage.py create_podcast_playlists --dry-run |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 6️⃣ بررسی نتیجه نهایی |
||||
|
|
||||
|
برای اطمینان از موفقیتآمیز بودن تمام مراحل: |
||||
|
|
||||
|
```bash |
||||
|
python manage.py shell -c " |
||||
|
from apps.podcast.models import Podcast, PodcastPlaylist, PodcastCollection, PodcastCategory, PlaylistItem |
||||
|
|
||||
|
print('=== Final Database State ===') |
||||
|
print(f'Podcasts: {Podcast.objects.count()}') |
||||
|
print(f'Playlists: {PodcastPlaylist.objects.count()}') |
||||
|
print(f'PlaylistItems: {PlaylistItem.objects.count()}') |
||||
|
print(f'Collections: {PodcastCollection.objects.count()}') |
||||
|
print(f'Categories: {PodcastCategory.objects.count()}') |
||||
|
|
||||
|
print('\n=== Sample Podcast ===') |
||||
|
p = Podcast.objects.first() |
||||
|
if p: |
||||
|
print(f'Title: {p.title}') |
||||
|
print(f'Slug: {p.slug}') |
||||
|
print(f'Audio file: {p.audio_file.name if p.audio_file else \"None\"}') |
||||
|
print(f'Duration: {p.audio_time}') |
||||
|
|
||||
|
print('\n=== Sample Playlist ===') |
||||
|
pl = PodcastPlaylist.objects.first() |
||||
|
if pl: |
||||
|
print(f'Title: {pl.title}') |
||||
|
print(f'Slug: {pl.slug}') |
||||
|
print(f'Podcasts in playlist: {pl.playlist_items.count()}') |
||||
|
print(f'Total time: {pl.total_time}') |
||||
|
" |
||||
|
``` |
||||
|
|
||||
|
**خروجی مورد انتظار:** |
||||
|
``` |
||||
|
=== Final Database State === |
||||
|
Podcasts: 31 |
||||
|
Playlists: 10 |
||||
|
PlaylistItems: 310 |
||||
|
Collections: 0 |
||||
|
Categories: 0 |
||||
|
|
||||
|
=== Sample Podcast === |
||||
|
Title: Жизнь Пророка Мухаммада |
||||
|
Slug: zhizn-proroka-mukhammada |
||||
|
Audio file: podcast/audio/zhizn-proroka-mukhammada.mp3 |
||||
|
Duration: 01:30:45 |
||||
|
|
||||
|
=== Sample Playlist === |
||||
|
Title: Лекции о Пророке Мухаммаде |
||||
|
Slug: lektsii-o-proroke-mukhammade |
||||
|
Podcasts in playlist: 31 |
||||
|
Total time: 1 day, 22:33:23 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📋 خلاصه دستورات (به ترتیب) |
||||
|
|
||||
|
```bash |
||||
|
# 1. اعمال migration |
||||
|
python manage.py migrate podcast |
||||
|
|
||||
|
# 2. پاکسازی دادههای قدیمی (اختیاری) |
||||
|
python manage.py cleanup_podcast_data --confirm |
||||
|
|
||||
|
# 3. ایجاد دستهبندیها (8 category) |
||||
|
python manage.py create_podcast_categories |
||||
|
|
||||
|
# 4. تبدیل ویدیوها به پادکست (مهم!) |
||||
|
python manage.py convert_videos_to_podcasts |
||||
|
|
||||
|
# 5. ایجاد پلیلیستها (با اتصال به categories) |
||||
|
python manage.py create_podcast_playlists |
||||
|
|
||||
|
# 6. بررسی نتیجه |
||||
|
python manage.py shell -c " |
||||
|
from apps.podcast.models import Podcast, PodcastPlaylist, PodcastCategory |
||||
|
print(f'Podcasts: {Podcast.objects.count()}') |
||||
|
print(f'Categories: {PodcastCategory.objects.count()}') |
||||
|
print(f'Playlists: {PodcastPlaylist.objects.count()}') |
||||
|
" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ⚠️ نکات مهم |
||||
|
|
||||
|
### فضای دیسک |
||||
|
- هر ویدیو حدوداً **40-50 MB** صدا تولید میکند |
||||
|
- برای 31 ویدیو، حدود **1.5 GB** فضا لازم است |
||||
|
|
||||
|
### زمان پردازش |
||||
|
- هر ویدیو حدوداً **30-60 ثانیه** زمان میبرد |
||||
|
- برای 31 ویدیو، حدود **20-30 دقیقه** زمان کل |
||||
|
|
||||
|
### پیشنیازها |
||||
|
- ✅ ffmpeg نصب باشد (از قبل نصب است) |
||||
|
- ✅ اتصال به دیتابیس فعال باشد |
||||
|
- ✅ فضای دیسک کافی موجود باشد |
||||
|
- ✅ ویدیوها در دیتابیس و سرور موجود باشند |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🐛 عیبیابی (Troubleshooting) |
||||
|
|
||||
|
### مشکل: ffmpeg not found |
||||
|
```bash |
||||
|
# بررسی نصب ffmpeg |
||||
|
which ffmpeg |
||||
|
ffmpeg -version |
||||
|
``` |
||||
|
|
||||
|
### مشکل: No space left on device |
||||
|
- فضای دیسک کافی نیست |
||||
|
- فایلهای موقت را پاک کنید |
||||
|
|
||||
|
### مشکل: Video file not found |
||||
|
- مسیر فایلهای ویدیو را بررسی کنید |
||||
|
- اطمینان حاصل کنید که فایلها در سرور موجود هستند |
||||
|
|
||||
|
### مشکل: Database connection error |
||||
|
- اتصال به دیتابیس را بررسی کنید |
||||
|
- صبر کنید و دوباره تلاش کنید |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ✅ پس از اتمام |
||||
|
|
||||
|
پس از اجرای موفقیتآمیز تمام مراحل، شما خواهید داشت: |
||||
|
|
||||
|
- ✅ **31 پادکست** (استخراج شده از ویدیوها) |
||||
|
- ✅ **10 پلیلیست** (با محتوای روسی) |
||||
|
- ✅ **310 آیتم در پلیلیستها** (هر پلیلیست شامل همه پادکستها) |
||||
|
- ✅ Thumbnail ها و توضیحات کپی شده |
||||
|
- ✅ سیستم آماده برای استفاده |
||||
|
|
||||
|
سیستم پادکست شما کاملاً آماده است! 🎉 |
||||
@ -0,0 +1,328 @@ |
|||||
|
# PlugNMeet Webhook Integration - Quick Setup Guide |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This project implements automatic webhook integration with PlugNMeet to handle live session events in real-time. |
||||
|
|
||||
|
## Features |
||||
|
|
||||
|
✅ **Room Management** |
||||
|
- Automatically close sessions when room ends |
||||
|
- Real-time session status updates |
||||
|
|
||||
|
✅ **Participant Tracking** |
||||
|
- Track when users join/leave sessions |
||||
|
- Maintain accurate online status |
||||
|
|
||||
|
✅ **Recording Management** |
||||
|
- Automatically download completed recordings |
||||
|
- Generate video thumbnails |
||||
|
- Save to database with metadata |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
### Required Software |
||||
|
|
||||
|
```bash |
||||
|
# Install FFmpeg (required for video thumbnail generation) |
||||
|
sudo apt-get update |
||||
|
sudo apt-get install ffmpeg |
||||
|
|
||||
|
# Verify installation |
||||
|
ffmpeg -version |
||||
|
``` |
||||
|
|
||||
|
### Django Settings |
||||
|
|
||||
|
Ensure these settings are configured in your `settings.py`: |
||||
|
|
||||
|
```python |
||||
|
# PlugNMeet Configuration |
||||
|
PLUGNMEET_SERVER_URL = "https://your-plugnmeet-server.com" |
||||
|
PLUGNMEET_API_KEY = "your-api-key" |
||||
|
PLUGNMEET_API_SECRET = "your-api-secret" |
||||
|
PLUGNMEET_TIMEOUT = 10.0 |
||||
|
|
||||
|
# Media files (for recordings) |
||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') |
||||
|
MEDIA_URL = '/media/' |
||||
|
``` |
||||
|
|
||||
|
## PlugNMeet Server Configuration |
||||
|
|
||||
|
Configure webhook in your PlugNMeet server settings: |
||||
|
|
||||
|
```yaml |
||||
|
# plugnmeet config.yaml |
||||
|
webhooks: |
||||
|
- url: "https://your-django-backend.com/api/course/plugnmeet/webhook/" |
||||
|
events: |
||||
|
- room_finished |
||||
|
- participant_joined |
||||
|
- participant_left |
||||
|
- end_recording |
||||
|
``` |
||||
|
|
||||
|
## Webhook Endpoint |
||||
|
|
||||
|
**URL:** `https://your-domain.com/api/course/plugnmeet/webhook/` |
||||
|
|
||||
|
**Method:** `POST` |
||||
|
|
||||
|
**Security:** HMAC SHA256 signature verification |
||||
|
|
||||
|
## Events Handled |
||||
|
|
||||
|
### 1. room_finished |
||||
|
- Closes the live session |
||||
|
- Marks all participants as offline |
||||
|
- Sets `ended_at` timestamp |
||||
|
|
||||
|
### 2. participant_joined |
||||
|
- Creates `LiveSessionUser` entry |
||||
|
- Sets user as online |
||||
|
- Records join timestamp |
||||
|
|
||||
|
### 3. participant_left |
||||
|
- Updates `LiveSessionUser` entry |
||||
|
- Sets user as offline |
||||
|
- Records exit timestamp |
||||
|
|
||||
|
### 4. end_recording |
||||
|
- Fetches recording from PlugNMeet |
||||
|
- Downloads recording file |
||||
|
- Saves to `LiveSessionRecording` model |
||||
|
- Generates video thumbnail (if applicable) |
||||
|
|
||||
|
## Testing |
||||
|
|
||||
|
### Using the Test Script |
||||
|
|
||||
|
```bash |
||||
|
# Test room_finished event |
||||
|
python scripts/test_webhook.py room_finished |
||||
|
|
||||
|
# Test participant_joined event |
||||
|
python scripts/test_webhook.py participant_joined |
||||
|
|
||||
|
# Test participant_left event |
||||
|
python scripts/test_webhook.py participant_left |
||||
|
|
||||
|
# Test end_recording event |
||||
|
python scripts/test_webhook.py end_recording |
||||
|
|
||||
|
# Dry run (show payload without sending) |
||||
|
python scripts/test_webhook.py room_finished --dry-run |
||||
|
``` |
||||
|
|
||||
|
### Manual Testing with cURL |
||||
|
|
||||
|
```bash |
||||
|
#!/bin/bash |
||||
|
|
||||
|
# Configuration |
||||
|
SECRET="your-api-secret" |
||||
|
URL="https://your-domain.com/api/course/plugnmeet/webhook/" |
||||
|
|
||||
|
# Sample payload |
||||
|
PAYLOAD='{ |
||||
|
"event": "room_finished", |
||||
|
"room": { |
||||
|
"identity": "test-room-20240101120000" |
||||
|
} |
||||
|
}' |
||||
|
|
||||
|
# Calculate signature |
||||
|
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) |
||||
|
|
||||
|
# Send request |
||||
|
curl -X POST "$URL" \ |
||||
|
-H "Content-Type: application/webhook+json" \ |
||||
|
-H "Hash-Token: $SIGNATURE" \ |
||||
|
-d "$PAYLOAD" |
||||
|
``` |
||||
|
|
||||
|
## Monitoring |
||||
|
|
||||
|
### Check Logs |
||||
|
|
||||
|
```bash |
||||
|
# Django logs |
||||
|
tail -f logs/django.log | grep "PlugNMeet Webhook" |
||||
|
|
||||
|
# Specific events |
||||
|
tail -f logs/django.log | grep "end_recording" |
||||
|
tail -f logs/django.log | grep "participant_joined" |
||||
|
``` |
||||
|
|
||||
|
### Log Messages |
||||
|
|
||||
|
``` |
||||
|
[PlugNMeet Webhook] Received webhook request |
||||
|
[PlugNMeet Webhook] Processing event=room_finished |
||||
|
[PlugNMeet Webhook] Session closed - session_id=123 room_id=test-room |
||||
|
[PlugNMeet Webhook] User sessions closed - session_id=123 count=5 |
||||
|
[PlugNMeet Webhook] Event processed successfully - event=room_finished |
||||
|
``` |
||||
|
|
||||
|
### Recording Download Logs |
||||
|
|
||||
|
``` |
||||
|
[PlugNMeet Webhook] end_recording - room_id=test-room recording_id=rec_123 |
||||
|
[PlugNMeet Webhook] Fetching recording info - recording_id=rec_123 |
||||
|
[PlugNMeet Webhook] Getting download token - recording_id=rec_123 |
||||
|
[PlugNMeet Webhook] Downloading recording file - recording_id=rec_123 |
||||
|
[PlugNMeet Webhook] File downloaded - size=524288000 bytes |
||||
|
[PlugNMeet Webhook] Recording saved - recording_id=456 file=recording.mp4 |
||||
|
[PlugNMeet Webhook] Thumbnail generated - recording_id=456 |
||||
|
``` |
||||
|
|
||||
|
## Database Models |
||||
|
|
||||
|
### CourseLiveSession |
||||
|
|
||||
|
```python |
||||
|
{ |
||||
|
'id': 123, |
||||
|
'course': Course instance, |
||||
|
'room_id': 'test-room-20240101120000', |
||||
|
'subject': 'Test Session', |
||||
|
'started_at': datetime, |
||||
|
'ended_at': datetime, # Set by webhook |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### LiveSessionUser |
||||
|
|
||||
|
```python |
||||
|
{ |
||||
|
'id': 456, |
||||
|
'session': CourseLiveSession instance, |
||||
|
'user': User instance, |
||||
|
'role': 'participant' or 'moderator', |
||||
|
'entered_at': datetime, # Set by webhook |
||||
|
'exited_at': datetime, # Set by webhook |
||||
|
'is_online': True/False, # Updated by webhook |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### LiveSessionRecording |
||||
|
|
||||
|
```python |
||||
|
{ |
||||
|
'id': 789, |
||||
|
'session': CourseLiveSession instance, |
||||
|
'title': 'Test Session - Recording', |
||||
|
'file': FileField, # Downloaded by webhook |
||||
|
'file_time': DurationField, |
||||
|
'recording_type': 'video' or 'voice', |
||||
|
'thumbnail': ImageField, # Generated by webhook |
||||
|
'is_active': True, |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Troubleshooting |
||||
|
|
||||
|
### Webhook Not Receiving Events |
||||
|
|
||||
|
1. Check PlugNMeet server configuration |
||||
|
2. Verify webhook URL is accessible from PlugNMeet server |
||||
|
3. Check firewall rules |
||||
|
4. Review PlugNMeet server logs |
||||
|
|
||||
|
### Signature Verification Failed |
||||
|
|
||||
|
1. Ensure `PLUGNMEET_API_SECRET` matches PlugNMeet config |
||||
|
2. Check for extra whitespace in settings |
||||
|
3. Verify request is coming from PlugNMeet server |
||||
|
|
||||
|
### Recording Download Failed |
||||
|
|
||||
|
1. Check PlugNMeet server is accessible |
||||
|
2. Verify recording exists: `POST /auth/recording/recordingInfo` |
||||
|
3. Check disk space |
||||
|
4. Review media directory permissions |
||||
|
|
||||
|
### Thumbnail Generation Failed |
||||
|
|
||||
|
1. Verify ffmpeg is installed: `ffmpeg -version` |
||||
|
2. Check ffmpeg has permissions to read/write temp files |
||||
|
3. Review video file format (mp4, webm, mkv supported) |
||||
|
4. Check server resources (CPU, memory) |
||||
|
|
||||
|
### File Upload Errors |
||||
|
|
||||
|
```python |
||||
|
# Check media directory permissions |
||||
|
ls -la media/ |
||||
|
chmod -R 755 media/ |
||||
|
|
||||
|
# Check Django settings |
||||
|
python manage.py shell |
||||
|
>>> from django.conf import settings |
||||
|
>>> print(settings.MEDIA_ROOT) |
||||
|
>>> print(settings.MEDIA_URL) |
||||
|
``` |
||||
|
|
||||
|
## Performance Considerations |
||||
|
|
||||
|
### Disk Space |
||||
|
|
||||
|
- Monitor disk space for recordings |
||||
|
- Implement cleanup policy for old recordings |
||||
|
- Consider using external storage (S3, MinIO) |
||||
|
|
||||
|
### Processing Time |
||||
|
|
||||
|
- Large recordings may take time to download |
||||
|
- Thumbnail generation adds 1-3 seconds per video |
||||
|
- Consider async processing for large files (Celery) |
||||
|
|
||||
|
### Concurrent Webhooks |
||||
|
|
||||
|
- Django handles webhooks synchronously by default |
||||
|
- For high-traffic scenarios, consider: |
||||
|
- Queue system (Celery, RQ) |
||||
|
- Async views (Django 4.1+) |
||||
|
- Horizontal scaling |
||||
|
|
||||
|
## Migration from Polling |
||||
|
|
||||
|
The old polling approach has been deprecated and commented out: |
||||
|
|
||||
|
```python |
||||
|
# OLD (Deprecated) - in apps/course/views/course.py |
||||
|
# def _sync_room_status_with_plugnmeet(self, course: Course): |
||||
|
# client = PlugNMeetClient() |
||||
|
# response = client.is_room_active(active_session.room_id) |
||||
|
# ... |
||||
|
|
||||
|
# NEW (Webhook-based) |
||||
|
# Room status is automatically updated via webhooks |
||||
|
# No polling required |
||||
|
``` |
||||
|
|
||||
|
## Security Best Practices |
||||
|
|
||||
|
1. ✅ **Signature Verification**: Always enabled (HMAC SHA256) |
||||
|
2. ✅ **HTTPS Only**: Webhook endpoint requires HTTPS |
||||
|
3. ✅ **IP Whitelist**: Consider restricting to PlugNMeet server IP |
||||
|
4. ✅ **Rate Limiting**: Implement rate limiting on webhook endpoint |
||||
|
5. ✅ **Input Validation**: All webhook payloads are validated |
||||
|
6. ✅ **Error Handling**: Comprehensive error handling and logging |
||||
|
|
||||
|
## Support |
||||
|
|
||||
|
For issues or questions: |
||||
|
|
||||
|
1. Check logs: `logs/django.log` |
||||
|
2. Review documentation: `docs/plugnmeet_webhook.md` |
||||
|
3. Test with script: `scripts/test_webhook.py` |
||||
|
4. Check PlugNMeet docs: https://www.plugnmeet.org/docs |
||||
|
|
||||
|
## References |
||||
|
|
||||
|
- [PlugNMeet Webhook Documentation](docs/plugnmeet_webhook.md) |
||||
|
- [PlugNMeet API Documentation](docs/plugnmeet_api.md) |
||||
|
- [Test Script](scripts/test_webhook.py) |
||||
|
- [Webhook Implementation](apps/course/views/webhook.py) |
||||
@ -0,0 +1,164 @@ |
|||||
|
# Video System Refactoring Summary |
||||
|
|
||||
|
## Overview |
||||
|
تغییرات اساسی در معماری سیستم ویدیو برای اصلاح ساختار Collection و Playlist |
||||
|
|
||||
|
## Changes Made |
||||
|
|
||||
|
### 1. Model Changes |
||||
|
|
||||
|
#### ❌ Removed: |
||||
|
- **VideoInCollection** model (مدل منسوخ شده که ویدیوها را مستقیماً به Collection ها متصل میکرد) |
||||
|
- **videos** field from VideoCollection (فیلد ManyToMany که ویدیوها را به Collection متصل میکرد) |
||||
|
|
||||
|
#### ✅ Kept: |
||||
|
- **VideoPlaylistInCollection** model (معماری صحیح که Playlist ها را به Collection متصل میکند) |
||||
|
- All other models: Video, VideoCategory, VideoCollection, VideoPlaylist, PlaylistItem |
||||
|
|
||||
|
### 2. Architecture Improvement |
||||
|
|
||||
|
**قبل:** |
||||
|
``` |
||||
|
VideoCollection --[VideoInCollection]--> Video |
||||
|
``` |
||||
|
|
||||
|
**بعد:** |
||||
|
``` |
||||
|
VideoCollection --[VideoPlaylistInCollection]--> VideoPlaylist --[PlaylistItem]--> Video |
||||
|
``` |
||||
|
|
||||
|
این تغییر باعث میشود: |
||||
|
- Collection ها شامل Playlist باشند (نه مستقیماً Video) |
||||
|
- سازماندهی بهتر محتوا |
||||
|
- معماری منطقیتر و قابل نگهداریتر |
||||
|
|
||||
|
### 3. Admin Panel Updates |
||||
|
|
||||
|
**apps/video/admin.py:** |
||||
|
- تغییر `VideoInCollectionInline` به `VideoPlaylistInCollectionInlineForCollection` |
||||
|
- تغییر `count_videos()` به `count_playlists()` در Collection admin |
||||
|
- حذف ارجاعات به VideoInCollection |
||||
|
|
||||
|
### 4. Migration |
||||
|
|
||||
|
**Migration File:** `0010_remove_videoincollection_model.py` |
||||
|
- حذف فیلد videos از videocollection |
||||
|
- حذف مدل VideoInCollection |
||||
|
|
||||
|
### 5. Management Commands |
||||
|
|
||||
|
#### cleanup_video_data.py |
||||
|
حذف تمام دادههای VideoCategory، VideoCollection، و VideoPlaylist (بدون حذف Video) |
||||
|
|
||||
|
**Usage:** |
||||
|
```bash |
||||
|
python manage.py cleanup_video_data --confirm |
||||
|
``` |
||||
|
|
||||
|
**Deleted:** |
||||
|
- 3 VideoCategories |
||||
|
- 4 VideoCollections |
||||
|
- 2 VideoPlaylists |
||||
|
- 3 PlaylistItems |
||||
|
|
||||
|
#### create_video_playlists.py |
||||
|
ایجاد 10 پلیلیست با محتوای روسی درباره پیامبران و امامان |
||||
|
|
||||
|
**Usage:** |
||||
|
```bash |
||||
|
python manage.py create_video_playlists |
||||
|
python manage.py create_video_playlists --dry-run # for testing |
||||
|
``` |
||||
|
|
||||
|
**Created:** |
||||
|
- 10 VideoPlaylists با عناوین و توضیحات روسی |
||||
|
- هر پلیلیست شامل تمام 31 ویدیو موجود |
||||
|
- محاسبه خودکار total_time برای هر پلیلیست |
||||
|
|
||||
|
**Playlists:** |
||||
|
1. Жизнь Пророка Мухаммада (да благословит его Аллах) |
||||
|
2. Истории пророков в Коране |
||||
|
3. Имам Али: Врата знаний |
||||
|
4. Имам Хусейн и трагедия Кербелы |
||||
|
5. Двенадцать Имамов Ахль аль-Байт |
||||
|
6. Фатима аз-Захра: Дочь Пророка |
||||
|
7. Имам Махди: Обещанный спаситель |
||||
|
8. Пророки и их чудеса |
||||
|
9. Учения Ахль аль-Байт о нравственности |
||||
|
10. Имам Риза и его наследие |
||||
|
|
||||
|
### 6. API Impact |
||||
|
|
||||
|
**Serializers:** No changes needed - already using correct `related_playlists` relationship |
||||
|
|
||||
|
**Views:** No changes needed - filtering and querying work correctly with new structure |
||||
|
|
||||
|
## Database State After Changes |
||||
|
|
||||
|
### Videos |
||||
|
- 31 videos (unchanged) |
||||
|
- No data loss |
||||
|
|
||||
|
### Playlists |
||||
|
- 10 new playlists |
||||
|
- Each contains all 31 videos |
||||
|
- Total duration per playlist: 1 day, 22:33:23 |
||||
|
|
||||
|
### Collections |
||||
|
- 0 collections (deleted and ready for new structure) |
||||
|
- Can now only contain playlists (not direct videos) |
||||
|
|
||||
|
### Categories |
||||
|
- 0 categories (deleted and ready for new data) |
||||
|
|
||||
|
## Next Steps |
||||
|
|
||||
|
1. ✅ Migration applied successfully |
||||
|
2. ✅ Old data cleaned up |
||||
|
3. ✅ New playlists created |
||||
|
4. 🔄 Create new VideoCollections and add playlists to them (if needed) |
||||
|
5. 🔄 Create new VideoCategories and assign to playlists (if needed) |
||||
|
6. 🔄 Test all API endpoints |
||||
|
|
||||
|
## Commands for Setup (in order) |
||||
|
|
||||
|
```bash |
||||
|
# 1. Apply migration (already done) |
||||
|
python manage.py migrate video |
||||
|
|
||||
|
# 2. Clean up old data (if needed) |
||||
|
python manage.py cleanup_video_data --confirm |
||||
|
|
||||
|
# 3. Create 8 video categories |
||||
|
python manage.py create_video_categories |
||||
|
|
||||
|
# 4. Create 10 playlists with all videos (automatically connects to categories) |
||||
|
python manage.py create_video_playlists |
||||
|
|
||||
|
# 5. Check current state |
||||
|
python manage.py shell -c " |
||||
|
from apps.video.models import Video, VideoPlaylist, VideoCollection, VideoCategory |
||||
|
print(f'Videos: {Video.objects.count()}') |
||||
|
print(f'Categories: {VideoCategory.objects.count()}') |
||||
|
print(f'Playlists: {VideoPlaylist.objects.count()}') |
||||
|
print(f'Collections: {VideoCollection.objects.count()}') |
||||
|
" |
||||
|
``` |
||||
|
|
||||
|
### Video Categories Created: |
||||
|
1. Пророки и посланники (Prophets and Messengers) |
||||
|
2. Имамы Ахль аль-Байт (Imams of Ahl al-Bayt) |
||||
|
3. Коранические истории (Quranic Stories) |
||||
|
4. Исламская философия (Islamic Philosophy) |
||||
|
5. Нравственность и этика (Morality and Ethics) |
||||
|
6. История ислама (History of Islam) |
||||
|
7. Кербела и Ашура (Karbala and Ashura) |
||||
|
8. Духовное развитие (Spiritual Development) |
||||
|
|
||||
|
## Important Notes |
||||
|
|
||||
|
- ⚠️ VideoInCollection model is completely removed - old code referencing it will break |
||||
|
- ✅ Videos are preserved - no video data was lost |
||||
|
- ✅ New architecture is more logical: Collections → Playlists → Videos |
||||
|
- ✅ Admin panel updated to reflect new structure |
||||
|
- ✅ API endpoints still work with no changes needed |
||||
@ -0,0 +1,607 @@ |
|||||
|
عالی! حالا که نمونه واقعی از تنظیمات 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 |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
] |
||||
@ -1,4 +1,74 @@ |
|||||
|
<<<<<<< HEAD |
||||
|
|
||||
from .user import * |
from .user import * |
||||
from .professor import * |
from .professor import * |
||||
from .student import * |
from .student import * |
||||
|
======= |
||||
|
from unfold.components import BaseComponent, register_component |
||||
|
from django.template.loader import render_to_string |
||||
|
|
||||
|
from .user import * |
||||
|
from .professor import * |
||||
|
from .student import * |
||||
|
from .location import * |
||||
|
|
||||
|
|
||||
|
@register_component |
||||
|
class AllUserComponent(BaseComponent): |
||||
|
def get_context_data(self, **kwargs): |
||||
|
context = super().get_context_data(**kwargs) |
||||
|
|
||||
|
context["children"] = render_to_string( |
||||
|
"admin/helpers/kpi_progress.html", |
||||
|
{ |
||||
|
"total": User.objects.filter(is_active=True).count(), |
||||
|
}, |
||||
|
) |
||||
|
return context |
||||
|
|
||||
|
@register_component |
||||
|
class GuestUserComponent(BaseComponent): |
||||
|
def get_context_data(self, **kwargs): |
||||
|
context = super().get_context_data(**kwargs) |
||||
|
|
||||
|
context["children"] = render_to_string( |
||||
|
"admin/helpers/kpi_progress.html", |
||||
|
{ |
||||
|
"total": User.objects.filter(email__isnull=True).count(), |
||||
|
}, |
||||
|
) |
||||
|
return context |
||||
|
|
||||
|
@register_component |
||||
|
class ProfessorUserComponent(BaseComponent): |
||||
|
def get_context_data(self, **kwargs): |
||||
|
context = super().get_context_data(**kwargs) |
||||
|
professor_count = User.objects.filter(groups__name="Professor Group").count() |
||||
|
context["children"] = render_to_string( |
||||
|
"admin/helpers/kpi_progress.html", |
||||
|
{ |
||||
|
"total": professor_count |
||||
|
}, |
||||
|
) |
||||
|
return context |
||||
|
|
||||
|
|
||||
|
|
||||
|
@register_component |
||||
|
class StudentUserComponent(BaseComponent): |
||||
|
def get_context_data(self, **kwargs): |
||||
|
context = super().get_context_data(**kwargs) |
||||
|
|
||||
|
student_count = User.objects.filter( |
||||
|
models.Q(groups__name="Student Group") | |
||||
|
models.Q(user_type=User.UserType.STUDENT) |
||||
|
).distinct().count() |
||||
|
|
||||
|
context["children"] = render_to_string( |
||||
|
"admin/helpers/kpi_progress.html", |
||||
|
{ |
||||
|
"total": student_count, |
||||
|
}, |
||||
|
) |
||||
|
return context |
||||
|
>>>>>>> develop |
||||
@ -0,0 +1,59 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin, TabularInline |
||||
|
from unfold.decorators import display |
||||
|
from unfold.contrib.filters.admin import ( |
||||
|
RangeDateTimeFilter, |
||||
|
TextFilter, |
||||
|
AutocompleteSelectFilter, |
||||
|
) |
||||
|
|
||||
|
from apps.account.models import LocationHistory, User |
||||
|
from utils.admin import project_admin_site, dovoodi_admin_site |
||||
|
|
||||
|
|
||||
|
class LocationHistoryInline(TabularInline): |
||||
|
model = LocationHistory |
||||
|
extra = 0 |
||||
|
tab = True |
||||
|
fields = ('lat', 'lon', 'country', 'city', 'selected_manually', 'ip', 'timezone', 'at_time') |
||||
|
readonly_fields = ('lat', 'lon', 'country', 'city', 'selected_manually', 'ip', 'timezone', 'at_time',) |
||||
|
verbose_name = _("Location History") |
||||
|
verbose_name_plural = _("Location History") |
||||
|
can_delete = False |
||||
|
show_change_link = True |
||||
|
|
||||
|
|
||||
|
class LocationHistoryAdmin(ModelAdmin): |
||||
|
list_display = ('user', 'display_location', 'country', 'city', 'selected_manually', 'ip', 'display_at_time') |
||||
|
list_filter = [ |
||||
|
('user', AutocompleteSelectFilter), |
||||
|
'country', |
||||
|
'city', |
||||
|
'selected_manually', |
||||
|
('at_time', RangeDateTimeFilter), |
||||
|
] |
||||
|
search_fields = ('user__email', 'user__fullname', 'country', 'city', 'ip') |
||||
|
readonly_fields = ('at_time',) |
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('user', ('lat', 'lon'), ('country', 'city')) |
||||
|
}), |
||||
|
(_('Additional Information'), { |
||||
|
'fields': ('selected_manually', 'ip', 'timezone', 'at_time'), |
||||
|
'classes': ('tab',), |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
@display(description=_("Location")) |
||||
|
def display_location(self, instance: LocationHistory): |
||||
|
return f"{instance.lat}, {instance.lon}" |
||||
|
|
||||
|
@display(description=_("Date & Time")) |
||||
|
def display_at_time(self, instance: LocationHistory): |
||||
|
return instance.at_time.strftime("%Y-%m-%d %H:%M") if instance.at_time else "-" |
||||
|
|
||||
|
|
||||
|
# Register with both admin sites |
||||
|
project_admin_site.register(LocationHistory, LocationHistoryAdmin) |
||||
|
dovoodi_admin_site.register(LocationHistory, LocationHistoryAdmin) |
||||
@ -0,0 +1,12 @@ |
|||||
|
from ajaxdatatable.admin import AjaxDatatable |
||||
|
|
||||
|
from apps.account.models import User, Notification |
||||
|
|
||||
|
@admin.register(Notification) |
||||
|
class NotificationAdmin(AjaxDatatable): |
||||
|
list_display = ('title', 'user', 'is_read', 'created_at') |
||||
|
list_filter = ('is_read', 'created_at') |
||||
|
search_fields = ('title', 'message', 'user__fullname') |
||||
|
list_editable = ('is_read',) |
||||
|
ordering = ('-created_at',) |
||||
|
autocomplete_fields = ['user',] |
||||
@ -0,0 +1,28 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.db import transaction |
||||
|
from django.db.models import Q |
||||
|
|
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = "Assign slugs to all professor users that currently lack one." |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
professors = User.objects.filter( |
||||
|
user_type=User.UserType.PROFESSOR |
||||
|
).filter(Q(slug__isnull=True) | Q(slug="")) |
||||
|
|
||||
|
if not professors.exists(): |
||||
|
self.stdout.write(self.style.SUCCESS("All professor users already have slugs.")) |
||||
|
return |
||||
|
|
||||
|
updated = 0 |
||||
|
with transaction.atomic(): |
||||
|
for professor in professors.iterator(): |
||||
|
if professor.ensure_professor_profile(): |
||||
|
updated += 1 |
||||
|
|
||||
|
self.stdout.write( |
||||
|
self.style.SUCCESS(f"Assigned slugs to {updated} professor user(s).") |
||||
|
) |
||||
@ -0,0 +1,158 @@ |
|||||
|
""" |
||||
|
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): |
||||
|
help = 'Migrate existing user data to multiple roles system' |
||||
|
|
||||
|
def add_arguments(self, parser): |
||||
|
parser.add_argument( |
||||
|
'--dry-run', |
||||
|
action='store_true', |
||||
|
help='Show what would be done without making changes', |
||||
|
) |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
dry_run = options['dry_run'] |
||||
|
|
||||
|
if dry_run: |
||||
|
self.stdout.write( |
||||
|
self.style.WARNING('DRY RUN MODE - No changes will be made') |
||||
|
) |
||||
|
|
||||
|
# اطمینان از وجود گروهها |
||||
|
self.ensure_groups_exist(dry_run) |
||||
|
|
||||
|
# 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!') |
||||
|
) |
||||
|
|
||||
|
def ensure_groups_exist(self, dry_run): |
||||
|
"""اطمینان از وجود گروههای مورد نیاز""" |
||||
|
groups = [ |
||||
|
"Professor Group", |
||||
|
"Student Group", |
||||
|
"Client Group", |
||||
|
"Admin Group", |
||||
|
"Super Admin Group" |
||||
|
] |
||||
|
|
||||
|
for group_name in groups: |
||||
|
if dry_run: |
||||
|
exists = Group.objects.filter(name=group_name).exists() |
||||
|
if not exists: |
||||
|
self.stdout.write(f'Would create group: {group_name}') |
||||
|
else: |
||||
|
group, created = Group.objects.get_or_create(name=group_name) |
||||
|
if created: |
||||
|
self.stdout.write(f'Created group: {group_name}') |
||||
|
|
||||
|
def migrate_user_types(self, dry_run): |
||||
|
"""Migration کاربران بر اساس user_type فعلی""" |
||||
|
users = User.objects.all() |
||||
|
|
||||
|
for user in users: |
||||
|
# چک کنیم که آیا کاربر قبلاً در گروه مناسب است یا خیر |
||||
|
expected_group_name = f"{user.user_type.capitalize()} Group" |
||||
|
|
||||
|
if not user.groups.filter(name=expected_group_name).exists(): |
||||
|
if dry_run: |
||||
|
self.stdout.write( |
||||
|
f'Would add user {user.email} to group {expected_group_name}' |
||||
|
) |
||||
|
else: |
||||
|
try: |
||||
|
group = Group.objects.get(name=expected_group_name) |
||||
|
user.groups.add(group) |
||||
|
self.stdout.write( |
||||
|
f'Added user {user.email} to group {expected_group_name}' |
||||
|
) |
||||
|
except Group.DoesNotExist: |
||||
|
self.stdout.write( |
||||
|
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): |
||||
|
"""نمایش آمار کاربران""" |
||||
|
total_users = User.objects.count() |
||||
|
professors = User.objects.filter(groups__name="Professor Group").count() |
||||
|
students = User.objects.filter(groups__name="Student Group").count() |
||||
|
clients = User.objects.filter(groups__name="Client Group").count() |
||||
|
|
||||
|
# کاربرانی که چندین نقش دارند |
||||
|
multi_role_users = User.objects.filter( |
||||
|
groups__name__in=["Professor Group", "Student Group"] |
||||
|
).annotate( |
||||
|
role_count=models.Count('groups') |
||||
|
).filter(role_count__gt=1).count() |
||||
|
|
||||
|
self.stdout.write('\n--- User Statistics ---') |
||||
|
self.stdout.write(f'Total users: {total_users}') |
||||
|
self.stdout.write(f'Professors: {professors}') |
||||
|
self.stdout.write(f'Students: {students}') |
||||
|
self.stdout.write(f'Clients: {clients}') |
||||
|
self.stdout.write(f'Multi-role users: {multi_role_users}') |
||||
@ -0,0 +1,114 @@ |
|||||
|
""" |
||||
|
Middleware برای محدود کردن دسترسی به admin panel |
||||
|
""" |
||||
|
from django.shortcuts import redirect |
||||
|
from django.urls import reverse |
||||
|
from django.contrib import messages |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
|
||||
|
class AdminAccessMiddleware: |
||||
|
"""Middleware برای کنترل دسترسی به admin panel""" |
||||
|
|
||||
|
def __init__(self, get_response): |
||||
|
self.get_response = get_response |
||||
|
|
||||
|
# مدلهایی که استادان نباید به آنها دسترسی داشته باشند |
||||
|
self.restricted_models = [ |
||||
|
'user', |
||||
|
'professoruser', |
||||
|
'studentuser', |
||||
|
'clientuser', |
||||
|
'transaction', |
||||
|
'transactionparticipant', |
||||
|
'book', |
||||
|
'bookcollection', |
||||
|
'article', |
||||
|
'podcast', |
||||
|
'chat', |
||||
|
'roommessage', |
||||
|
'hadis', |
||||
|
'hadiscategory', |
||||
|
'globalpreference', |
||||
|
'coursecategory', |
||||
|
] |
||||
|
|
||||
|
# URL patterns که استادان نباید به آنها دسترسی داشته باشند |
||||
|
self.restricted_urls = [ |
||||
|
'/admin/account/', |
||||
|
'/admin/transaction/', |
||||
|
'/admin/library/', |
||||
|
'/admin/article/', |
||||
|
'/admin/podcast/', |
||||
|
'/admin/chat/', |
||||
|
'/admin/hadis/', |
||||
|
'/admin/dynamic_preferences/', |
||||
|
'/admin/course/coursecategory/', |
||||
|
] |
||||
|
|
||||
|
def __call__(self, request): |
||||
|
# بررسی دسترسی قبل از پردازش request |
||||
|
if self.should_restrict_access(request): |
||||
|
return self.handle_restricted_access(request) |
||||
|
|
||||
|
response = self.get_response(request) |
||||
|
return response |
||||
|
|
||||
|
def should_restrict_access(self, request): |
||||
|
"""آیا باید دسترسی محدود شود؟""" |
||||
|
# فقط برای admin URLs |
||||
|
if not request.path.startswith('/admin/'): |
||||
|
return False |
||||
|
|
||||
|
# اولویت اول: staff یا admin - دسترسی کامل بدون محدودیت |
||||
|
if (request.user.is_authenticated and |
||||
|
(request.user.is_staff or |
||||
|
request.user.has_role('admin') or |
||||
|
request.user.has_role('super_admin'))): |
||||
|
return False |
||||
|
|
||||
|
# اگر کاربر احراز هویت نشده، دسترسی ندارد |
||||
|
if not request.user.is_authenticated: |
||||
|
return True |
||||
|
|
||||
|
# اگر کاربر استاد نیست، دسترسی ندارد |
||||
|
if not (request.user.is_authenticated and request.user.has_role('professor')): |
||||
|
return True |
||||
|
|
||||
|
# برای استادان: بررسی URL های محدود شده |
||||
|
for restricted_url in self.restricted_urls: |
||||
|
if request.path.startswith(restricted_url): |
||||
|
return True |
||||
|
|
||||
|
# برای استادان: بررسی مدلهای محدود شده |
||||
|
path_parts = request.path.strip('/').split('/') |
||||
|
if len(path_parts) >= 3: # admin/app/model/ |
||||
|
app_name = path_parts[1] |
||||
|
model_name = path_parts[2] |
||||
|
|
||||
|
if model_name in self.restricted_models: |
||||
|
return True |
||||
|
|
||||
|
return False |
||||
|
|
||||
|
def handle_restricted_access(self, request): |
||||
|
"""مدیریت دسترسی محدود شده""" |
||||
|
if not request.user.is_authenticated: |
||||
|
return redirect('admin:login') |
||||
|
|
||||
|
# اگر کاربر استاد است، در همان admin panel میماند |
||||
|
if request.user.is_authenticated and request.user.has_role('professor'): |
||||
|
# فقط پیام میدهیم که دسترسی محدود است |
||||
|
messages.info( |
||||
|
request, |
||||
|
_('You have limited access as a professor.') |
||||
|
) |
||||
|
# به صفحه اصلی admin هدایت میکنیم |
||||
|
return redirect('/admin/') |
||||
|
|
||||
|
# سایر کاربران |
||||
|
messages.error( |
||||
|
request, |
||||
|
_('You do not have permission to access this page.') |
||||
|
) |
||||
|
return redirect('admin:login') |
||||
@ -0,0 +1,20 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-04-04 00:09 |
||||
|
|
||||
|
import phonenumber_field.modelfields |
||||
|
import utils.validators |
||||
|
from django.db import migrations |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('account', '0001_initial'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='user', |
||||
|
name='phone_number', |
||||
|
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +49 151 12345678', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Phone Number'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,30 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-05-01 15:54 |
||||
|
|
||||
|
import django.db.models.deletion |
||||
|
from django.conf import settings |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('account', '0002_alter_user_phone_number'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='LocationHistory', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('lat', models.FloatField(verbose_name='lat')), |
||||
|
('lon', models.FloatField(verbose_name='lon')), |
||||
|
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='country')), |
||||
|
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='city')), |
||||
|
('selected_manually', models.BooleanField(blank=True, null=True)), |
||||
|
('ip', models.CharField(blank=True, max_length=255, null=True)), |
||||
|
('timezone', models.CharField(blank=True, max_length=60, null=True)), |
||||
|
('at_time', models.DateTimeField(auto_now_add=True)), |
||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='location_history', to=settings.AUTH_USER_MODEL)), |
||||
|
], |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,18 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-09-02 18:36 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('account', '0003_locationhistory'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='user', |
||||
|
name='avatar', |
||||
|
field=models.ImageField(blank=True, max_length=512, null=True, upload_to='users/avatars/%Y/%m/'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,17 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-22 17:18 |
||||
|
|
||||
|
from django.db import migrations |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('account', '0004_alter_user_avatar'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterUniqueTogether( |
||||
|
name='user', |
||||
|
unique_together={('email',)}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,45 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-10-06 11:01 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
from django.utils.text import slugify |
||||
|
|
||||
|
|
||||
|
def generate_professor_slugs(apps, schema_editor): |
||||
|
User = apps.get_model('account', 'User') |
||||
|
qs = User.objects.filter(user_type='professor').filter(models.Q(slug__isnull=True) | models.Q(slug='')) |
||||
|
for user in qs.iterator(): |
||||
|
base = slugify(user.fullname, allow_unicode=True) if user.fullname else '' |
||||
|
base = base[:250] or f"professor-{user.pk}" |
||||
|
slug = base |
||||
|
counter = 1 |
||||
|
while User.objects.filter(slug=slug).exclude(pk=user.pk).exists(): |
||||
|
slug = f"{base}-{counter}"[:255] |
||||
|
counter += 1 |
||||
|
user.slug = slug |
||||
|
user.save(update_fields=['slug']) |
||||
|
|
||||
|
|
||||
|
def remove_professor_slugs(apps, schema_editor): |
||||
|
User = apps.get_model('account', 'User') |
||||
|
User.objects.filter(user_type='professor').update(slug=None) |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('account', '0005_alter_user_unique_together'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name='user', |
||||
|
name='experience_years', |
||||
|
field=models.PositiveIntegerField(default=0, verbose_name='Experience years'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='user', |
||||
|
name='slug', |
||||
|
field=models.SlugField(blank=True, max_length=255, null=True, unique=True), |
||||
|
), |
||||
|
migrations.RunPython(generate_professor_slugs, remove_professor_slugs), |
||||
|
] |
||||
@ -0,0 +1,17 @@ |
|||||
|
# Generated by Django 5.2.9 on 2025-12-09 15:13 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
dependencies = [ |
||||
|
("account", "0006_auto_20251006_1101"), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name="user", |
||||
|
name="user_agent", |
||||
|
field=models.TextField(blank=True, null=True, verbose_name="user agent"), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,22 @@ |
|||||
|
# Generated by Django 5.2.9 on 2025-12-09 15:13 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
dependencies = [ |
||||
|
("account", "0007_user_user_agent"), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name="loginhistory", |
||||
|
name="device_os", |
||||
|
field=models.CharField(blank=True, max_length=16, null=True), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name="loginhistory", |
||||
|
name="user_agent", |
||||
|
field=models.TextField(blank=True, null=True, verbose_name="user agent"), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,17 @@ |
|||||
|
# Generated by Django 5.2.9 on 2025-12-09 15:37 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
dependencies = [ |
||||
|
("account", "0008_loginhistory_device_os_loginhistory_user_agent"), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name="user", |
||||
|
name="client_ip", |
||||
|
field=models.TextField(blank=True, null=True, verbose_name="client ip"), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,21 @@ |
|||||
|
# Generated by Django 5.2.9 on 2025-12-09 15:52 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
dependencies = [ |
||||
|
("account", "0009_user_client_ip"), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name="user", |
||||
|
name="device_os", |
||||
|
field=models.CharField( |
||||
|
choices=[("android", "android"), ("apple", "apple"), ("web", "web")], |
||||
|
max_length=16, |
||||
|
null=True, |
||||
|
), |
||||
|
), |
||||
|
] |
||||
@ -1,2 +1,7 @@ |
|||||
from .user import * |
from .user import * |
||||
from .notification import * |
from .notification import * |
||||
|
<<<<<<< HEAD |
||||
|
======= |
||||
|
from .location_history import * |
||||
|
from .auth import * |
||||
|
>>>>>>> develop |
||||
@ -0,0 +1,11 @@ |
|||||
|
from rest_framework import serializers |
||||
|
|
||||
|
|
||||
|
class ExchangeTokenSerializer(serializers.Serializer): |
||||
|
temp_token = serializers.CharField(max_length=128) |
||||
|
|
||||
|
def validate_temp_token(self, value: str) -> str: |
||||
|
value = value.strip() |
||||
|
if not value: |
||||
|
raise serializers.ValidationError("temp_token is required.") |
||||
|
return value |
||||
@ -0,0 +1,37 @@ |
|||||
|
from rest_framework import serializers |
||||
|
|
||||
|
from apps.account.models import LocationHistory |
||||
|
|
||||
|
|
||||
|
class LocationHistorySerializer(serializers.ModelSerializer): |
||||
|
user = serializers.HiddenField(default=serializers.CurrentUserDefault()) |
||||
|
class Meta: |
||||
|
model = LocationHistory |
||||
|
exclude = ('at_time',) |
||||
|
|
||||
|
class ReverseGeolocationSerializer(serializers.Serializer): |
||||
|
"""Serializer for reverse geolocation request query parameters""" |
||||
|
lat = serializers.FloatField( |
||||
|
required=True, |
||||
|
min_value=-90.0, |
||||
|
max_value=90.0, |
||||
|
help_text="Latitude coordinate (-90 to 90)" |
||||
|
) |
||||
|
lon = serializers.FloatField( |
||||
|
required=True, |
||||
|
min_value=-180.0, |
||||
|
max_value=180.0, |
||||
|
help_text="Longitude coordinate (-180 to 180)" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class ReverseGeolocationResponseSerializer(serializers.Serializer): |
||||
|
"""Serializer for reverse geolocation response""" |
||||
|
latitude = serializers.FloatField(read_only=True) |
||||
|
longitude = serializers.FloatField(read_only=True) |
||||
|
city = serializers.CharField(max_length=100, allow_null=True, read_only=True) |
||||
|
country = serializers.CharField(max_length=100, allow_null=True, read_only=True) |
||||
|
country_code = serializers.CharField(max_length=10, allow_null=True, read_only=True) |
||||
|
accuracy_radius = serializers.IntegerField(allow_null=True, read_only=True, required=False) |
||||
|
time_zone = serializers.CharField(max_length=100, allow_null=True, allow_blank=True, read_only=True, required=False) |
||||
|
postal_code = serializers.CharField(max_length=20, allow_null=True, allow_blank=True, read_only=True, required=False) |
||||
@ -0,0 +1,29 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from django.contrib.auth.password_validation import validate_password |
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class WebUserRegisterSerializer(serializers.ModelSerializer): |
||||
|
password = serializers.CharField(write_only=True, validators=[validate_password]) |
||||
|
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True) |
||||
|
email = serializers.EmailField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['id', 'fullname', 'email', 'password', 'fcm'] |
||||
|
extra_kwargs = { |
||||
|
'fullname': {'required': True}, |
||||
|
'email': {'required': True}, |
||||
|
} |
||||
|
|
||||
|
def validate_email(self, value): |
||||
|
normalized_email = User.objects.normalize_email(value) |
||||
|
if User.objects.filter(email=normalized_email).exists(): |
||||
|
raise serializers.ValidationError("This email is already registered.") |
||||
|
return normalized_email |
||||
|
|
||||
|
def create(self, validated_data): |
||||
|
user = super().create(validated_data) |
||||
|
return user |
||||
|
|
||||
|
|
||||
@ -0,0 +1,40 @@ |
|||||
|
|
||||
|
{% load unfold i18n %} |
||||
|
|
||||
|
<div class="border border-base-300 border-dashed mb-4 p-3 rounded dark:border-base-700"> |
||||
|
{% trans "Driver before template" %} |
||||
|
</div> |
||||
|
|
||||
|
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 "> |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Active drivers" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverActiveComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Inactive drivers" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverInactiveComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total points" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverTotalPointsComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total races" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverRacesComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
</div> |
||||
@ -0,0 +1,800 @@ |
|||||
|
{% load i18n %} |
||||
|
<div class="json-editor-container"> |
||||
|
<textarea style="display: none" name="{{ widget.name }}" id="{{ widget.attrs.id }}" |
||||
|
{% include "django/forms/widgets/attrs.html" %} |
||||
|
>{% if widget.value %}{{ widget.value }}{% endif %}</textarea> |
||||
|
<div class="json-view-editor" id='date-view-editor-{{ widget.attrs.id }}'></div> |
||||
|
</div> |
||||
|
|
||||
|
<script defer="defer"> |
||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||
|
function initJsonEditor() { |
||||
|
let editor_ = document.getElementById("{{ widget.attrs.id }}"); |
||||
|
if (!editor_) { |
||||
|
console.error("Editor element not found"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
let startValue; |
||||
|
try { |
||||
|
startValue = editor_.value && editor_.value.trim() !== '' ? JSON.parse(editor_.value) : []; |
||||
|
} catch (e) { |
||||
|
console.error("Error parsing JSON value:", e); |
||||
|
startValue = []; |
||||
|
} |
||||
|
|
||||
|
let jsonViewerDiv = document.getElementById('date-view-editor-{{ widget.attrs.id }}'); |
||||
|
|
||||
|
if (typeof JSONEditor === 'undefined') { |
||||
|
console.error("JSONEditor is not defined. Make sure the library is loaded."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Custom template for add button |
||||
|
JSONEditor.defaults.templates.button = function(text, icon, title) { |
||||
|
let el = document.createElement('button'); |
||||
|
el.type = 'button'; |
||||
|
el.classList.add('json-editor-btn-modern'); |
||||
|
|
||||
|
if (icon) { |
||||
|
let iconEl = document.createElement('span'); |
||||
|
iconEl.classList.add('json-editor-btn-icon'); |
||||
|
iconEl.innerHTML = icon; |
||||
|
el.appendChild(iconEl); |
||||
|
} |
||||
|
|
||||
|
if (text) { |
||||
|
let textEl = document.createElement('span'); |
||||
|
textEl.classList.add('json-editor-btn-text'); |
||||
|
textEl.textContent = text; |
||||
|
el.appendChild(textEl); |
||||
|
} |
||||
|
|
||||
|
if (title) el.title = title; |
||||
|
|
||||
|
return el; |
||||
|
}; |
||||
|
|
||||
|
// Custom icons |
||||
|
JSONEditor.defaults.iconlib = { |
||||
|
getIcon: function(key) { |
||||
|
switch(key) { |
||||
|
case 'add': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>'; |
||||
|
case 'delete': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>'; |
||||
|
case 'edit': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>'; |
||||
|
case 'moveup': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>'; |
||||
|
case 'movedown': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>'; |
||||
|
default: |
||||
|
return ''; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
try { |
||||
|
let jsonEditor = new JSONEditor( |
||||
|
jsonViewerDiv, { |
||||
|
theme: 'bootstrap4', |
||||
|
schema: {{ widget.attrs.schema|safe }}, |
||||
|
disable_edit_json: true, |
||||
|
disable_properties: true, |
||||
|
disable_array_delete_all_rows: false, |
||||
|
disable_array_delete_last_row: true, // Disable delete last row button |
||||
|
disable_array_reorder: true, // Disable array reordering |
||||
|
grid_columns: 12, |
||||
|
prompt_before_delete: true, |
||||
|
disable_collapse: false, // Enable collapse to show button sections |
||||
|
show_errors: 'always', |
||||
|
startval: startValue, |
||||
|
iconlib: 'custom', |
||||
|
object_layout: 'normal', // Changed from grid to normal for better layout |
||||
|
enable_array_copy: false, // Disable copy functionality |
||||
|
show_opt_in: false, |
||||
|
compact: false, |
||||
|
array_controls_top: false, // Move array controls to bottom |
||||
|
show_button_bar: true, // Show button bar |
||||
|
form_name_root: 'root' // Add a root name for better structure |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
// Store the editor instance on the textarea |
||||
|
editor_.editor = jsonEditor; |
||||
|
|
||||
|
// Update the textarea when the editor changes |
||||
|
jsonEditor.on('change', function() { |
||||
|
editor_.value = JSON.stringify(jsonEditor.getValue()); |
||||
|
|
||||
|
// Trigger a change event on the textarea |
||||
|
let event = new Event('change', { bubbles: true }); |
||||
|
editor_.dispatchEvent(event); |
||||
|
|
||||
|
// Apply styling to newly added elements |
||||
|
applyCustomStyling(); |
||||
|
}); |
||||
|
|
||||
|
// Function to apply custom styling to all elements |
||||
|
function applyCustomStyling() { |
||||
|
// Add modern styling to buttons |
||||
|
const allButtons = jsonViewerDiv.querySelectorAll('button'); |
||||
|
allButtons.forEach(button => { |
||||
|
if (!button.classList.contains('json-editor-btn-modern')) { |
||||
|
button.classList.add('json-editor-btn-modern'); |
||||
|
|
||||
|
// Add specific styling based on button type |
||||
|
if (button.classList.contains('json-editor-btntype-add')) { |
||||
|
button.classList.add('json-editor-btn-add'); |
||||
|
} else if (button.classList.contains('json-editor-btntype-delete') || |
||||
|
button.classList.contains('json-editor-btntype-deleteall') || |
||||
|
button.classList.contains('json-editor-btntype-deletelast')) { |
||||
|
button.classList.add('json-editor-btn-delete'); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Style form controls |
||||
|
const formControls = jsonViewerDiv.querySelectorAll('input, select, textarea'); |
||||
|
formControls.forEach(control => { |
||||
|
control.classList.add('modern-form-control'); |
||||
|
}); |
||||
|
|
||||
|
// Style table headers |
||||
|
const tableHeaders = jsonViewerDiv.querySelectorAll('th'); |
||||
|
tableHeaders.forEach(header => { |
||||
|
header.classList.add('modern-table-header'); |
||||
|
}); |
||||
|
|
||||
|
// Style table rows |
||||
|
const tableRows = jsonViewerDiv.querySelectorAll('tr'); |
||||
|
tableRows.forEach(row => { |
||||
|
row.classList.add('modern-table-row'); |
||||
|
}); |
||||
|
|
||||
|
// Make table full width |
||||
|
const tables = jsonViewerDiv.querySelectorAll('table'); |
||||
|
tables.forEach(table => { |
||||
|
table.classList.add('full-width-table'); |
||||
|
}); |
||||
|
|
||||
|
// Make table cells take equal space |
||||
|
const tableCells = jsonViewerDiv.querySelectorAll('td'); |
||||
|
tableCells.forEach(cell => { |
||||
|
if (!cell.classList.contains('table-controls-cell')) { |
||||
|
cell.classList.add('equal-width-cell'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Add special styling to control cells |
||||
|
const controlCells = jsonViewerDiv.querySelectorAll('td:last-child'); |
||||
|
controlCells.forEach(cell => { |
||||
|
cell.classList.add('table-controls-cell'); |
||||
|
}); |
||||
|
|
||||
|
// Fix button group styling |
||||
|
const buttonGroups = jsonViewerDiv.querySelectorAll('.btn-group, .json-editor-btngroup'); |
||||
|
buttonGroups.forEach(group => { |
||||
|
group.classList.add('modern-btn-group'); |
||||
|
}); |
||||
|
|
||||
|
// Fix card styling |
||||
|
const cards = jsonViewerDiv.querySelectorAll('.card'); |
||||
|
cards.forEach(card => { |
||||
|
card.classList.add('modern-card'); |
||||
|
}); |
||||
|
|
||||
|
// Ensure button sections are visible |
||||
|
const buttonSections = jsonViewerDiv.querySelectorAll('.json-editor-btngroup'); |
||||
|
buttonSections.forEach(section => { |
||||
|
section.style.display = 'flex'; |
||||
|
section.style.visibility = 'visible'; |
||||
|
}); |
||||
|
|
||||
|
// Style button bars |
||||
|
const buttonBars = jsonViewerDiv.querySelectorAll('.json-editor-btn-bar'); |
||||
|
buttonBars.forEach(bar => { |
||||
|
bar.style.display = 'flex'; |
||||
|
bar.style.flexWrap = 'wrap'; |
||||
|
bar.style.gap = '0.5rem'; |
||||
|
bar.style.marginTop = '1rem'; |
||||
|
bar.style.marginBottom = '0'; |
||||
|
bar.style.padding = '0.75rem'; |
||||
|
bar.style.backgroundColor = 'rgba(1, 53, 59, 0.05)'; |
||||
|
bar.style.borderRadius = '0.5rem'; |
||||
|
bar.style.width = '100%'; |
||||
|
bar.style.justifyContent = 'flex-end'; |
||||
|
|
||||
|
// Move button bar to the end of its parent container |
||||
|
const parent = bar.parentElement; |
||||
|
if (parent) { |
||||
|
parent.style.display = 'flex'; |
||||
|
parent.style.flexDirection = 'column'; |
||||
|
parent.appendChild(bar); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Hide specific buttons (Copy, Move up, Move down, Delete Last) |
||||
|
const buttonsToHide = jsonViewerDiv.querySelectorAll('.json-editor-btntype-copy, .json-editor-btntype-move, .json-editor-btntype-deletelast'); |
||||
|
buttonsToHide.forEach(button => { |
||||
|
button.style.display = 'none'; |
||||
|
}); |
||||
|
|
||||
|
// Ensure form fields take full width |
||||
|
const formRows = jsonViewerDiv.querySelectorAll('.row'); |
||||
|
formRows.forEach(row => { |
||||
|
row.style.width = '100%'; |
||||
|
const cols = row.querySelectorAll('[class*="col-"]'); |
||||
|
cols.forEach(col => { |
||||
|
col.style.width = '100%'; |
||||
|
col.style.maxWidth = '100%'; |
||||
|
col.style.flex = '0 0 100%'; |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Apply styling immediately after initialization |
||||
|
setTimeout(applyCustomStyling, 100); |
||||
|
|
||||
|
// Process error messages to make them HTML5-like |
||||
|
function processErrorMessages() { |
||||
|
const errorElements = jsonViewerDiv.querySelectorAll('.je-error'); |
||||
|
errorElements.forEach(error => { |
||||
|
// Get the error message text |
||||
|
const errorText = error.textContent.trim(); |
||||
|
|
||||
|
// Set the data-content attribute for the tooltip |
||||
|
error.setAttribute('data-content', errorText); |
||||
|
|
||||
|
// Find the parent form group |
||||
|
const formGroup = error.closest('.form-group'); |
||||
|
if (formGroup) { |
||||
|
formGroup.classList.add('has-error'); |
||||
|
|
||||
|
// Find the input element |
||||
|
const input = formGroup.querySelector('input, select, textarea'); |
||||
|
if (input) { |
||||
|
// Add error class to the input |
||||
|
input.classList.add('is-invalid'); |
||||
|
|
||||
|
// Add title attribute for native tooltip |
||||
|
input.setAttribute('title', errorText); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Remove "Delete Last Course Features" button if it exists |
||||
|
function removeDeleteLastButton() { |
||||
|
const deleteLastButtons = jsonViewerDiv.querySelectorAll('button.json-editor-btntype-deletelast'); |
||||
|
deleteLastButtons.forEach(button => { |
||||
|
const buttonText = button.textContent.trim(); |
||||
|
if (buttonText.includes('Delete Last') && buttonText.includes('Course Features')) { |
||||
|
button.style.display = 'none'; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Add mutation observer to apply styling to dynamically added elements |
||||
|
const observer = new MutationObserver(function(mutations) { |
||||
|
applyCustomStyling(); |
||||
|
processErrorMessages(); |
||||
|
removeDeleteLastButton(); |
||||
|
}); |
||||
|
|
||||
|
observer.observe(jsonViewerDiv, { |
||||
|
childList: true, |
||||
|
subtree: true |
||||
|
}); |
||||
|
|
||||
|
// Initial processing |
||||
|
setTimeout(() => { |
||||
|
processErrorMessages(); |
||||
|
removeDeleteLastButton(); |
||||
|
}, 200); |
||||
|
|
||||
|
} catch (e) { |
||||
|
console.error("Error initializing JSONEditor:", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Initialize the editor |
||||
|
if (document.readyState === 'complete') { |
||||
|
initJsonEditor(); |
||||
|
} else { |
||||
|
window.addEventListener('load', initJsonEditor); |
||||
|
} |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
/* Modern JSON Editor Container - Unfold theme */ |
||||
|
.json-editor-container { |
||||
|
margin-bottom: 1.5rem; |
||||
|
background-color: #0C0B1D; |
||||
|
border-radius: 0.5rem; |
||||
|
overflow: hidden; |
||||
|
width: 100%; |
||||
|
box-shadow: 0 1px 3px rgba(1, 53, 59, 0.08); |
||||
|
border: 1px solid rgba(1, 53, 59, 0.05); |
||||
|
} |
||||
|
|
||||
|
/* Editor container */ |
||||
|
.json-view-editor { |
||||
|
width: 100%; |
||||
|
border: none; |
||||
|
padding: 1.25rem; |
||||
|
} |
||||
|
|
||||
|
/* Card styling */ |
||||
|
.card { |
||||
|
border: none !important; |
||||
|
margin-bottom: 1.25rem !important; |
||||
|
background-color: transparent !important; |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
padding: 0.75rem 0 !important; |
||||
|
} |
||||
|
|
||||
|
/* Hide unnecessary elements */ |
||||
|
.json-view-editor .card-title { |
||||
|
display: none !important; |
||||
|
} |
||||
|
|
||||
|
/* Modern JSON Editor styling */ |
||||
|
.json-editor-modern { |
||||
|
border: none !important; |
||||
|
box-shadow: none !important; |
||||
|
} |
||||
|
|
||||
|
.jsoneditor-menu { |
||||
|
display: none !important; |
||||
|
} |
||||
|
|
||||
|
/* Modern card styling */ |
||||
|
.modern-card { |
||||
|
border: none !important; |
||||
|
box-shadow: none !important; |
||||
|
background: transparent !important; |
||||
|
margin-bottom: 1rem !important; |
||||
|
padding: 0 !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
padding: 0 !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
|
||||
|
/* Table styling */ |
||||
|
.full-width-table { |
||||
|
width: 100% !important; |
||||
|
margin-bottom: 1rem !important; |
||||
|
border-collapse: separate !important; |
||||
|
border-spacing: 0 !important; |
||||
|
} |
||||
|
|
||||
|
.table-responsive { |
||||
|
border: 1px solid rgba(1, 53, 59, 0.1); |
||||
|
border-radius: 0.5rem; |
||||
|
overflow: hidden; |
||||
|
margin-bottom: 1.25rem; |
||||
|
background-color: white; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); |
||||
|
} |
||||
|
|
||||
|
.equal-width-cell { |
||||
|
width: 45% !important; |
||||
|
} |
||||
|
|
||||
|
.table-controls-cell { |
||||
|
width: 10% !important; |
||||
|
text-align: right !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-header { |
||||
|
background-color: rgba(1, 53, 59, 0.03) !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
font-weight: 600 !important; |
||||
|
text-transform: uppercase !important; |
||||
|
font-size: 0.6875rem !important; |
||||
|
letter-spacing: 0.05em !important; |
||||
|
padding: 0.625rem 0.875rem !important; |
||||
|
border-bottom: 1px solid rgba(1, 53, 59, 0.08) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row { |
||||
|
border-bottom: 1px solid rgba(1, 53, 59, 0.06) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row:last-child { |
||||
|
border-bottom: none !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row:hover { |
||||
|
background-color: rgba(37, 208, 118, 0.03) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row td { |
||||
|
padding: 0.625rem 0.875rem !important; |
||||
|
vertical-align: middle !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Modern buttons - Using Unfold color scheme */ |
||||
|
.json-editor-btn-modern { |
||||
|
display: inline-flex !important; |
||||
|
align-items: center !important; |
||||
|
justify-content: center !important; |
||||
|
gap: 0.375rem !important; |
||||
|
background-color: rgb(37, 208, 118) !important; /* Unfold primary-500 */ |
||||
|
color: white !important; |
||||
|
border: none !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
padding: 0.5rem 0.875rem !important; /* Smaller padding */ |
||||
|
font-weight: 500 !important; |
||||
|
cursor: pointer !important; |
||||
|
transition: all 0.2s ease !important; |
||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important; |
||||
|
margin: 0 0.25rem !important; |
||||
|
font-size: 0.8125rem !important; /* Smaller font */ |
||||
|
line-height: 1.4 !important; |
||||
|
text-transform: none !important; |
||||
|
letter-spacing: 0.01em !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-modern:hover { |
||||
|
background-color: rgb(29, 166, 94) !important; /* Unfold primary-600 */ |
||||
|
transform: translateY(-1px) !important; |
||||
|
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08) !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-modern:active { |
||||
|
transform: translateY(0) !important; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08) !important; |
||||
|
} |
||||
|
|
||||
|
/* Button sections styling */ |
||||
|
.json-editor-btngroup, .json-editor-btn-bar { |
||||
|
display: flex !important; |
||||
|
flex-wrap: wrap !important; |
||||
|
gap: 0.5rem !important; |
||||
|
margin-top: 1rem !important; |
||||
|
margin-bottom: 0 !important; |
||||
|
padding: 0.75rem !important; |
||||
|
background-color: rgba(1, 53, 59, 0.05) !important; |
||||
|
border-radius: 0.5rem !important; |
||||
|
visibility: visible !important; |
||||
|
width: 100% !important; |
||||
|
justify-content: flex-end !important; |
||||
|
} |
||||
|
|
||||
|
/* Button icons */ |
||||
|
.json-editor-btn-icon { |
||||
|
display: flex !important; |
||||
|
align-items: center !important; |
||||
|
justify-content: center !important; |
||||
|
} |
||||
|
|
||||
|
/* Add button styling */ |
||||
|
.json-editor-btn-add { |
||||
|
background-color: rgb(37, 208, 118) !important; /* Unfold primary-500 */ |
||||
|
padding: 0.625rem 1rem !important; /* Smaller padding */ |
||||
|
font-size: 0.875rem !important; /* Smaller font */ |
||||
|
font-weight: 600 !important; |
||||
|
letter-spacing: 0.01em !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
width: auto !important; /* Not full width */ |
||||
|
margin-bottom: 0.75rem !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-add:hover { |
||||
|
background-color: rgb(29, 166, 94) !important; /* Unfold primary-600 */ |
||||
|
} |
||||
|
|
||||
|
/* Modern HTML5-like error styling */ |
||||
|
.je-error-container { |
||||
|
display: none !important; /* Hide the default error container */ |
||||
|
} |
||||
|
|
||||
|
/* Style for invalid inputs */ |
||||
|
.je-error + input, |
||||
|
.je-error + select, |
||||
|
.je-error + textarea, |
||||
|
.has-error .modern-form-control { |
||||
|
border-color: rgb(239, 68, 68) !important; |
||||
|
padding-right: 2.5rem !important; |
||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); |
||||
|
background-repeat: no-repeat; |
||||
|
background-position: right 0.75rem center; |
||||
|
background-size: 1.25rem; |
||||
|
} |
||||
|
|
||||
|
/* Custom tooltip for errors */ |
||||
|
.je-error { |
||||
|
position: relative !important; |
||||
|
display: inline-block !important; |
||||
|
color: transparent !important; |
||||
|
font-size: 0 !important; |
||||
|
width: 0 !important; |
||||
|
height: 0 !important; |
||||
|
overflow: visible !important; |
||||
|
} |
||||
|
|
||||
|
.je-error::after { |
||||
|
content: attr(data-content) !important; |
||||
|
position: absolute !important; |
||||
|
bottom: 125% !important; |
||||
|
right: 0 !important; |
||||
|
visibility: hidden !important; |
||||
|
width: 200px !important; |
||||
|
background-color: rgb(239, 68, 68) !important; |
||||
|
color: white !important; |
||||
|
text-align: center !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
padding: 0.5rem 0.75rem !important; |
||||
|
font-size: 0.8125rem !important; |
||||
|
font-weight: 500 !important; |
||||
|
opacity: 0 !important; |
||||
|
transition: opacity 0.3s !important; |
||||
|
z-index: 100 !important; |
||||
|
pointer-events: none !important; |
||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important; |
||||
|
} |
||||
|
|
||||
|
/* Show tooltip on hover over the input */ |
||||
|
.je-error + input:hover + .je-error::after, |
||||
|
.je-error + select:hover + .je-error::after, |
||||
|
.je-error + textarea:hover + .je-error::after, |
||||
|
.has-error .modern-form-control:hover + .je-error::after { |
||||
|
visibility: visible !important; |
||||
|
opacity: 1 !important; |
||||
|
} |
||||
|
|
||||
|
/* Arrow for tooltip */ |
||||
|
.je-error::before { |
||||
|
content: "" !important; |
||||
|
position: absolute !important; |
||||
|
bottom: 125% !important; |
||||
|
right: 10px !important; |
||||
|
visibility: hidden !important; |
||||
|
border-width: 5px !important; |
||||
|
border-style: solid !important; |
||||
|
border-color: rgb(239, 68, 68) transparent transparent transparent !important; |
||||
|
opacity: 0 !important; |
||||
|
transition: opacity 0.3s !important; |
||||
|
} |
||||
|
|
||||
|
.je-error + input:hover + .je-error::before, |
||||
|
.je-error + select:hover + .je-error::before, |
||||
|
.je-error + textarea:hover + .je-error::before, |
||||
|
.has-error .modern-form-control:hover + .je-error::before { |
||||
|
visibility: visible !important; |
||||
|
opacity: 1 !important; |
||||
|
} |
||||
|
|
||||
|
/* Delete button styling */ |
||||
|
.json-editor-btn-delete { |
||||
|
background-color: rgb(1, 53, 59) !important; /* Unfold secondary-500 */ |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-delete:hover { |
||||
|
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
/* Hide Delete Last buttons */ |
||||
|
.json-editor-btntype-deletelast { |
||||
|
display: none !important; |
||||
|
} |
||||
|
|
||||
|
/* Move up/down buttons */ |
||||
|
.json-editor-btntype-moveup, .json-editor-btntype-movedown { |
||||
|
background-color: rgba(1, 53, 59, 0.8) !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btntype-moveup:hover, .json-editor-btntype-movedown:hover { |
||||
|
background-color: rgb(1, 53, 59) !important; |
||||
|
} |
||||
|
|
||||
|
/* Button sections styling */ |
||||
|
.json-editor-btngroup, .json-editor-btn-bar { |
||||
|
display: flex !important; |
||||
|
flex-wrap: wrap !important; |
||||
|
gap: 0.375rem !important; |
||||
|
margin-top: 1rem !important; |
||||
|
padding: 0.625rem !important; |
||||
|
background-color: rgba(1, 53, 59, 0.03) !important; /* More subtle background */ |
||||
|
border: 1px solid rgba(1, 53, 59, 0.08) !important; /* Subtle border */ |
||||
|
border-radius: 0.5rem !important; |
||||
|
visibility: visible !important; |
||||
|
width: 100% !important; |
||||
|
justify-content: flex-end !important; |
||||
|
order: 999 !important; /* Ensure it appears at the bottom */ |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; /* Subtle shadow */ |
||||
|
} |
||||
|
|
||||
|
/* Modern button group styling */ |
||||
|
.modern-btn-group { |
||||
|
display: inline-flex !important; |
||||
|
gap: 0.25rem !important; |
||||
|
margin: 0.125rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Form controls - Unfold theme */ |
||||
|
.modern-form-control { |
||||
|
width: 100% !important; |
||||
|
border: 1px solid rgba(1, 53, 59, 0.15) !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
padding: 0.625rem 0.875rem !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
line-height: 1.5 !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
background-color: #fff !important; |
||||
|
transition: all 0.2s ease !important; |
||||
|
appearance: none !important; |
||||
|
margin-bottom: 0.875rem !important; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control:focus { |
||||
|
border-color: rgb(37, 208, 118) !important; |
||||
|
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.15) !important; |
||||
|
outline: none !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control::placeholder { |
||||
|
color: rgba(1, 53, 59, 0.4) !important; |
||||
|
} |
||||
|
|
||||
|
/* Form labels */ |
||||
|
label, .je-label { |
||||
|
font-size: 0.875rem !important; |
||||
|
font-weight: 500 !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
margin-bottom: 0.375rem !important; |
||||
|
display: block !important; |
||||
|
} |
||||
|
|
||||
|
/* Button group styling */ |
||||
|
.modern-btn-group { |
||||
|
display: flex !important; |
||||
|
flex-wrap: nowrap !important; |
||||
|
align-items: center !important; |
||||
|
justify-content: flex-end !important; |
||||
|
gap: 0.5rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Error message styling */ |
||||
|
.invalid-feedback { |
||||
|
color: #ef4444 !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
margin-top: 0.25rem !important; |
||||
|
margin-bottom: 0.5rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Labels */ |
||||
|
label, .je-label { |
||||
|
display: block !important; |
||||
|
margin-bottom: 0.5rem !important; |
||||
|
font-weight: 500 !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
font-size: 0.9375rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Form groups */ |
||||
|
.form-group, .je-object__container { |
||||
|
margin-bottom: 1.5rem !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
|
||||
|
/* Make the JSON editor more responsive */ |
||||
|
@media (max-width: 767px) { |
||||
|
.modern-form-control { |
||||
|
font-size: 0.875rem !important; |
||||
|
padding: 0.5rem 0.625rem !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-modern { |
||||
|
padding: 0.375rem 0.75rem !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-header { |
||||
|
font-size: 0.75rem !important; |
||||
|
padding: 0.625rem 0.75rem !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row td { |
||||
|
padding: 0.625rem 0.75rem !important; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* Dark mode support - Using Unfold color scheme */ |
||||
|
@media (prefers-color-scheme: dark) { |
||||
|
.json-editor-container { |
||||
|
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */ |
||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); |
||||
|
} |
||||
|
|
||||
|
.json-view-editor { |
||||
|
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
.table-responsive { |
||||
|
border-color: rgb(1, 36, 40) !important; /* Unfold secondary-700 */ |
||||
|
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-header { |
||||
|
background-color: rgb(1, 30, 34) !important; /* Unfold secondary-800 */ |
||||
|
color: white !important; |
||||
|
border-bottom-color: rgb(0, 26, 29) !important; /* Unfold secondary-900 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-row { |
||||
|
border-bottom-color: rgb(1, 30, 34) !important; /* Unfold secondary-800 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-row:hover { |
||||
|
background-color: rgb(1, 36, 40) !important; /* Unfold secondary-700 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-row td { |
||||
|
color: white !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control { |
||||
|
background-color: rgb(1, 36, 40) !important; /* Unfold secondary-700 */ |
||||
|
border-color: rgb(1, 30, 34) !important; /* Unfold secondary-800 */ |
||||
|
color: white !important; |
||||
|
} |
||||
|
|
||||
|
label, .je-label { |
||||
|
color: white !important; |
||||
|
} |
||||
|
|
||||
|
/* Button sections in dark mode */ |
||||
|
.json-editor-btngroup, .json-editor-btn-bar { |
||||
|
background-color: rgba(1, 30, 34, 0.25) !important; /* More subtle dark background */ |
||||
|
border: 1px solid rgba(37, 208, 118, 0.1) !important; /* Subtle primary color border */ |
||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control:focus { |
||||
|
border-color: rgb(37, 208, 118) !important; /* Unfold primary-500 */ |
||||
|
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.2) !important; |
||||
|
} |
||||
|
|
||||
|
/* Error styling in dark mode */ |
||||
|
.je-error + input, |
||||
|
.je-error + select, |
||||
|
.je-error + textarea, |
||||
|
.has-error .modern-form-control { |
||||
|
border-color: rgb(252, 165, 165) !important; /* Lighter red for dark mode */ |
||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fca5a5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); |
||||
|
} |
||||
|
|
||||
|
.je-error::after { |
||||
|
background-color: rgb(185, 28, 28) !important; /* Darker red background for tooltip */ |
||||
|
color: white !important; |
||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25) !important; |
||||
|
} |
||||
|
|
||||
|
.je-error::before { |
||||
|
border-color: rgb(185, 28, 28) transparent transparent transparent !important; |
||||
|
} |
||||
|
|
||||
|
/* Card styling in dark mode */ |
||||
|
.modern-card { |
||||
|
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
/* Error messages in dark mode */ |
||||
|
.invalid-feedback { |
||||
|
color: #f87171 !important; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
@ -0,0 +1,33 @@ |
|||||
|
|
||||
|
{% load unfold i18n %} |
||||
|
|
||||
|
|
||||
|
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 "> |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Actice Users" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="AllUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Guest Users" %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/title.html" with component_class="GuestUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Students" %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/title.html" with component_class="StudentUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Professors" %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/title.html" with component_class="ProfessorUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
</div> |
||||
@ -0,0 +1,240 @@ |
|||||
|
""" |
||||
|
تستهای سیستم نقشهای چندگانه |
||||
|
""" |
||||
|
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): |
||||
|
def setUp(self): |
||||
|
"""راهاندازی دادههای تست""" |
||||
|
# ایجاد گروهها |
||||
|
self.professor_group = Group.objects.create(name="Professor Group") |
||||
|
self.student_group = Group.objects.create(name="Student Group") |
||||
|
self.client_group = Group.objects.create(name="Client Group") |
||||
|
|
||||
|
# ایجاد کاربر |
||||
|
self.user = User.objects.create_user( |
||||
|
email='test@example.com', |
||||
|
fullname='Test User', |
||||
|
password='testpass123' |
||||
|
) |
||||
|
# حذف language برای جلوگیری از خطای foreign key |
||||
|
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): |
||||
|
"""تست اینکه کاربر میتواند چندین نقش داشته باشد""" |
||||
|
# اضافه کردن نقش professor |
||||
|
self.user.add_role('professor') |
||||
|
self.assertTrue(self.user.has_role('professor')) |
||||
|
self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR) |
||||
|
|
||||
|
# اضافه کردن نقش student |
||||
|
self.user.add_role('student') |
||||
|
self.assertTrue(self.user.has_role('student')) |
||||
|
self.assertTrue(self.user.has_role('professor')) # نقش قبلی حفظ شده |
||||
|
|
||||
|
# نقش اصلی باید professor باشد (اولویت بالاتر) |
||||
|
self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR) |
||||
|
|
||||
|
# لیست تمام نقشها |
||||
|
roles = self.user.get_all_roles() |
||||
|
self.assertIn('professor', roles) |
||||
|
self.assertIn('student', roles) |
||||
|
|
||||
|
def test_remove_role(self): |
||||
|
"""تست حذف نقش""" |
||||
|
# اضافه کردن دو نقش |
||||
|
self.user.add_role('professor') |
||||
|
self.user.add_role('student') |
||||
|
|
||||
|
# حذف نقش professor |
||||
|
self.user.remove_role('professor') |
||||
|
self.assertFalse(self.user.has_role('professor')) |
||||
|
self.assertTrue(self.user.has_role('student')) |
||||
|
|
||||
|
# نقش اصلی باید 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): |
||||
|
"""تست دسترسیها""" |
||||
|
# کاربر بدون نقش خاص |
||||
|
self.assertFalse(self.user.can_teach_course()) |
||||
|
self.assertTrue(self.user.can_enroll_course()) |
||||
|
|
||||
|
# اضافه کردن نقش professor |
||||
|
self.user.add_role('professor') |
||||
|
self.assertTrue(self.user.can_teach_course()) |
||||
|
self.assertTrue(self.user.can_enroll_course()) |
||||
|
|
||||
|
# حذف نقش professor |
||||
|
self.user.remove_role('professor') |
||||
|
self.assertFalse(self.user.can_teach_course()) |
||||
|
self.assertTrue(self.user.can_enroll_course()) |
||||
|
|
||||
|
def test_user_type_based_on_groups_compatibility(self): |
||||
|
"""تست سازگاری با property قدیمی""" |
||||
|
# اضافه کردن نقش student |
||||
|
self.user.add_role('student') |
||||
|
self.user.refresh_from_db() # بروزرسانی از دیتابیس |
||||
|
self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT) |
||||
|
|
||||
|
# اضافه کردن نقش professor |
||||
|
self.user.add_role('professor') |
||||
|
self.user.refresh_from_db() # بروزرسانی از دیتابیس |
||||
|
# property قدیمی بر اساس اولویت کار میکند - student اول چک میشود |
||||
|
# پس باید student برگرداند نه professor |
||||
|
self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT) |
||||
|
|
||||
|
# حذف نقش student |
||||
|
self.user.remove_role('student') |
||||
|
self.user.refresh_from_db() |
||||
|
self.assertEqual(self.user.user_type_based_on_groups, User.UserType.PROFESSOR) |
||||
|
|
||||
|
# حذف همه نقشها |
||||
|
self.user.remove_role('professor') |
||||
|
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)) |
||||
@ -1,3 +1,9 @@ |
|||||
from .user import * |
from .user import * |
||||
from .notification import * |
from .notification import * |
||||
|
<<<<<<< HEAD |
||||
|
======= |
||||
|
from .location_history import * |
||||
|
from .auth import * |
||||
|
|
||||
|
>>>>>>> develop |
||||
|
|
||||
@ -0,0 +1,126 @@ |
|||||
|
import logging |
||||
|
|
||||
|
from django.contrib.auth import get_user_model |
||||
|
|
||||
|
from drf_yasg import openapi |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from rest_framework import status |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from rest_framework.generics import GenericAPIView |
||||
|
from rest_framework.permissions import AllowAny |
||||
|
from rest_framework.response import Response |
||||
|
|
||||
|
from apps.account.serializers import ExchangeTokenSerializer |
||||
|
from utils import absolute_url |
||||
|
from utils.redis import OnlineClassTokenManager |
||||
|
|
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
UserModel = get_user_model() |
||||
|
|
||||
|
|
||||
|
class ExchangeTokenAPIView(GenericAPIView): |
||||
|
""" |
||||
|
تبدیل temporary token به اطلاعات کاربر برای ورود از اپ موبایل |
||||
|
""" |
||||
|
permission_classes = [AllowAny] |
||||
|
serializer_class = ExchangeTokenSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Exchange temporary token for user information and authentication token.", |
||||
|
request_body=ExchangeTokenSerializer, |
||||
|
responses={ |
||||
|
status.HTTP_200_OK: openapi.Response( |
||||
|
description="Token exchanged successfully.", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"success": True, |
||||
|
"message": "ورود موفق", |
||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", |
||||
|
"user": { |
||||
|
"id": 123, |
||||
|
"fullname": "علی احمدی", |
||||
|
"email": "user@example.com", |
||||
|
"avatar": "https://cdn.example.com/avatar.jpg" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
), |
||||
|
status.HTTP_400_BAD_REQUEST: openapi.Response( |
||||
|
description="Invalid request.", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"success": False, |
||||
|
"message": "توکن ارسال نشده است" |
||||
|
} |
||||
|
} |
||||
|
), |
||||
|
status.HTTP_404_NOT_FOUND: openapi.Response( |
||||
|
description="Token not found or expired.", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"success": False, |
||||
|
"message": "توکن نامعتبر یا منقضی شده است" |
||||
|
} |
||||
|
} |
||||
|
), |
||||
|
} |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
|
||||
|
temp_token = serializer.validated_data['temp_token'] |
||||
|
|
||||
|
# دریافت اطلاعات از Redis/Cache |
||||
|
manager = OnlineClassTokenManager() |
||||
|
try: |
||||
|
token_data = manager.get_payload(temp_token) |
||||
|
except Exception: |
||||
|
return Response({ |
||||
|
'success': False, |
||||
|
'message': 'توکن نامعتبر یا منقضی شده است' |
||||
|
}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
user_id = token_data.get('user_id') |
||||
|
if not user_id: |
||||
|
return Response({ |
||||
|
'success': False, |
||||
|
'message': 'توکن نامعتبر است' |
||||
|
}, status=status.HTTP_400_BAD_REQUEST) |
||||
|
|
||||
|
# دریافت کاربر |
||||
|
try: |
||||
|
user = UserModel.objects.get(id=user_id) |
||||
|
except UserModel.DoesNotExist: |
||||
|
return Response({ |
||||
|
'success': False, |
||||
|
'message': 'کاربر یافت نشد' |
||||
|
}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
# حذف توکن موقت (one-time use) |
||||
|
manager.delete_token(temp_token) |
||||
|
|
||||
|
# دریافت یا تولید Token واقعی کاربر |
||||
|
auth_token, _ = Token.objects.get_or_create(user=user) |
||||
|
|
||||
|
# دریافت avatar URL |
||||
|
avatar_url = None |
||||
|
if hasattr(user, 'avatar') and user.avatar: |
||||
|
try: |
||||
|
avatar_url = absolute_url(user.avatar.url) |
||||
|
except Exception: |
||||
|
avatar_url = None |
||||
|
|
||||
|
# برگرداندن اطلاعات کاربر با token واقعی |
||||
|
return Response({ |
||||
|
'success': True, |
||||
|
'message': 'ورود موفق', |
||||
|
'token': auth_token.key, |
||||
|
'user': { |
||||
|
'id': user.id, |
||||
|
'fullname': user.get_full_name() or user.username or '', |
||||
|
'email': user.email or '', |
||||
|
'avatar': avatar_url |
||||
|
} |
||||
|
}, status=status.HTTP_200_OK) |
||||
@ -0,0 +1,358 @@ |
|||||
|
|
||||
|
import logging |
||||
|
import re |
||||
|
from pathlib import Path |
||||
|
from rest_framework.mixins import CreateModelMixin |
||||
|
from rest_framework.permissions import IsAuthenticated |
||||
|
from rest_framework.generics import GenericAPIView |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework.views import APIView |
||||
|
from rest_framework import status |
||||
|
from apps.account.models import LocationHistory |
||||
|
from apps.account.serializers import LocationHistorySerializer, ReverseGeolocationSerializer, ReverseGeolocationResponseSerializer |
||||
|
import geoip2.database |
||||
|
import geoip2.errors |
||||
|
from city_detection_ip import get_location_by_coordinates, get_location_by_ip, SPECIAL_COORDINATES |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from drf_yasg import openapi |
||||
|
|
||||
|
class LocationHistoryView(GenericAPIView, CreateModelMixin): |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
serializer_class = LocationHistorySerializer |
||||
|
|
||||
|
def post(self, request, *args, **kwargs): |
||||
|
ip = self.get_client_ip() |
||||
|
data = request.data.copy() |
||||
|
data['ip'] = ip |
||||
|
serializer = self.get_serializer(data=data) |
||||
|
|
||||
|
if serializer.is_valid(): |
||||
|
serializer.save(user=request.user) |
||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED) |
||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return LocationHistory.objects.filter(user=self.request.user) |
||||
|
|
||||
|
def get_client_ip(self): |
||||
|
# Retrieve the client's IP address from the request headers |
||||
|
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') |
||||
|
if x_forwarded_for: |
||||
|
ip = x_forwarded_for.split(',')[0] |
||||
|
else: |
||||
|
ip = self.request.META.get('REMOTE_ADDR') |
||||
|
return ip |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
# GeoLite2 database path |
||||
|
CITY_DB_PATH = Path("utils/country_city_db/GeoLite2-City.mmdb") |
||||
|
|
||||
|
|
||||
|
def detect_browser_from_user_agent(user_agent): |
||||
|
""" |
||||
|
Detect browser name from User-Agent string. |
||||
|
|
||||
|
Args: |
||||
|
user_agent (str): The User-Agent header value |
||||
|
|
||||
|
Returns: |
||||
|
str or None: Browser name if detected, None if not a browser or detection fails |
||||
|
""" |
||||
|
if not user_agent: |
||||
|
return None |
||||
|
|
||||
|
try: |
||||
|
user_agent = user_agent.lower() |
||||
|
|
||||
|
# Check for Flutter/Dart app (return None for non-browser requests) |
||||
|
if any(keyword in user_agent for keyword in ['dart:io', 'flutter', 'dart/']): |
||||
|
return None |
||||
|
|
||||
|
# Check for mobile apps that might not be browsers |
||||
|
if any(keyword in user_agent for keyword in ['habibapp', 'mobile app']): |
||||
|
return None |
||||
|
|
||||
|
# Browser detection patterns (order matters - more specific first) |
||||
|
browser_patterns = [ |
||||
|
(r'edg/', 'Edge'), # Microsoft Edge (Chromium-based) |
||||
|
(r'edge/', 'Edge'), # Microsoft Edge (Legacy) |
||||
|
(r'opr/', 'Opera'), # Opera |
||||
|
(r'opera/', 'Opera'), # Opera |
||||
|
(r'chrome/', 'Chrome'), # Google Chrome |
||||
|
(r'chromium/', 'Chromium'), # Chromium |
||||
|
(r'firefox/', 'Firefox'), # Mozilla Firefox |
||||
|
(r'fxios/', 'Firefox'), # Firefox for iOS |
||||
|
(r'safari/', 'Safari'), # Safari (check after Chrome/Edge as they also contain Safari) |
||||
|
(r'version/.*safari', 'Safari'), # Safari with version |
||||
|
] |
||||
|
|
||||
|
# Check each pattern |
||||
|
for pattern, browser_name in browser_patterns: |
||||
|
if re.search(pattern, user_agent): |
||||
|
# Additional check for Safari to avoid false positives |
||||
|
if browser_name == 'Safari': |
||||
|
# Make sure it's not Chrome, Edge, or other browsers that include Safari in UA |
||||
|
if not any(other in user_agent for other in ['chrome', 'edg', 'opr', 'opera']): |
||||
|
return browser_name |
||||
|
else: |
||||
|
return browser_name |
||||
|
|
||||
|
# If no specific browser detected but contains Mozilla, it might be an unknown browser |
||||
|
if 'mozilla' in user_agent and any(keyword in user_agent for keyword in ['gecko', 'webkit']): |
||||
|
return 'Unknown Browser' |
||||
|
|
||||
|
return None |
||||
|
|
||||
|
except Exception as e: |
||||
|
# Log the error but don't let it break the API |
||||
|
logger.warning(f"Error detecting browser from user agent: {e}") |
||||
|
return None |
||||
|
|
||||
|
class RegionInfoView(GenericAPIView): |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
# Get browser information safely |
||||
|
browser = None |
||||
|
try: |
||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '') |
||||
|
browser = detect_browser_from_user_agent(user_agent) |
||||
|
except Exception as e: |
||||
|
# Log the error but continue with the API response |
||||
|
logger.warning(f"Error detecting browser in RegionInfoView: {e}") |
||||
|
browser = None |
||||
|
|
||||
|
# Get IP address |
||||
|
ip = self.get_client_ip(request) |
||||
|
|
||||
|
# Get geolocation data from GeoIP2 database |
||||
|
geo_data = self.get_location_from_ip(ip) |
||||
|
|
||||
|
region_info = { |
||||
|
'ip': request.META.get('HTTP_CF_CONNECTING_IP') or ip, |
||||
|
'country': request.META.get('HTTP_CF_IPCOUNTRY'), |
||||
|
'region': request.META.get('HTTP_CF_REGION'), |
||||
|
'region_code': request.META.get('HTTP_CF_REGION_CODE'), |
||||
|
'city': request.META.get('HTTP_CF_CITY'), |
||||
|
'timezone': request.META.get('HTTP_CF_TIMEZONE'), |
||||
|
'browser': browser, |
||||
|
} |
||||
|
|
||||
|
# Add geolocation data if available |
||||
|
if geo_data: |
||||
|
region_info.update({ |
||||
|
'country_code': geo_data.get('country_code'), |
||||
|
'latitude': geo_data.get('latitude'), |
||||
|
'longitude': geo_data.get('longitude'), |
||||
|
'accuracy_radius': geo_data.get('accuracy_radius'), |
||||
|
'time_zone': geo_data.get('time_zone'), |
||||
|
'postal_code': geo_data.get('postal_code'), |
||||
|
}) |
||||
|
|
||||
|
return Response(region_info) |
||||
|
|
||||
|
def get_client_ip(self, request): |
||||
|
"""Extract client IP from request""" |
||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') |
||||
|
if x_forwarded_for: |
||||
|
ip = x_forwarded_for.split(',')[0].strip() |
||||
|
else: |
||||
|
ip = request.META.get('REMOTE_ADDR') |
||||
|
return ip |
||||
|
|
||||
|
def get_location_from_ip(self, ip): |
||||
|
"""Get location data from IP using GeoIP2 database""" |
||||
|
try: |
||||
|
# Skip local/private IPs |
||||
|
if ip in ['127.0.0.1', 'localhost'] or ip.startswith('192.168.') or ip.startswith('10.'): |
||||
|
logger.warning(f"Skipping local/private IP: {ip}") |
||||
|
return { |
||||
|
'ip': ip, |
||||
|
'city': None, |
||||
|
'country': None, |
||||
|
'country_code': None, |
||||
|
'latitude': None, |
||||
|
'longitude': None, |
||||
|
'accuracy_radius': None, |
||||
|
'time_zone': None, |
||||
|
'postal_code': None, |
||||
|
} |
||||
|
|
||||
|
if not CITY_DB_PATH.exists(): |
||||
|
logger.error(f"GeoIP2 database not found at {CITY_DB_PATH}") |
||||
|
return None |
||||
|
|
||||
|
with geoip2.database.Reader(CITY_DB_PATH) as reader: |
||||
|
response = reader.city(ip) |
||||
|
|
||||
|
# Extract city name with validation |
||||
|
city_name = None |
||||
|
if response.city and response.city.name: |
||||
|
# Check if city name is actually a subdivision (region) |
||||
|
# This is a known issue in GeoIP2 where subdivision names appear as city names |
||||
|
subdivision_names = [s.name for s in response.subdivisions] if response.subdivisions else [] |
||||
|
|
||||
|
if response.city.name not in subdivision_names: |
||||
|
# City name is valid - not a subdivision |
||||
|
city_name = response.city.name |
||||
|
else: |
||||
|
# City name matches a subdivision - this is a region, not a city |
||||
|
logger.warning(f"IP {ip}: City name '{response.city.name}' matches subdivision - treating as region") |
||||
|
city_name = None # Don't return region as city |
||||
|
|
||||
|
location_data = { |
||||
|
'ip': ip, |
||||
|
'city': city_name, |
||||
|
'country': response.country.name if response.country else None, |
||||
|
'country_code': response.country.iso_code if response.country else None, |
||||
|
'latitude': response.location.latitude if response.location else None, |
||||
|
'longitude': response.location.longitude if response.location else None, |
||||
|
'accuracy_radius': response.location.accuracy_radius if response.location else None, |
||||
|
'time_zone': response.location.time_zone if response.location else None, |
||||
|
'postal_code': response.postal.code if response.postal else None, |
||||
|
} |
||||
|
|
||||
|
logger.info(f"Successfully found location for IP {ip}: {location_data.get('city')}, {location_data.get('country')}") |
||||
|
return location_data |
||||
|
|
||||
|
except geoip2.errors.AddressNotFoundError: |
||||
|
logger.warning(f"IP address {ip} not found in GeoIP2 database") |
||||
|
return None |
||||
|
except Exception as e: |
||||
|
logger.error(f"Error getting location from IP {ip}: {str(e)}") |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
class ReverseGeolocationAPIView(APIView): |
||||
|
""" |
||||
|
API endpoint to get location information from geographic coordinates |
||||
|
Returns: city, country, country_code based on latitude and longitude |
||||
|
""" |
||||
|
permission_classes = [] |
||||
|
|
||||
|
def validate_city_name_from_coordinates(self, lat, lon, city_name): |
||||
|
""" |
||||
|
Validate that the city name is not actually a subdivision (region). |
||||
|
Uses keyword-based heuristic to detect subdivision names. |
||||
|
|
||||
|
Args: |
||||
|
lat: Latitude coordinate |
||||
|
lon: Longitude coordinate |
||||
|
city_name: City name to validate |
||||
|
|
||||
|
Returns: |
||||
|
Validated city name or None if it's a subdivision |
||||
|
""" |
||||
|
if not city_name: |
||||
|
return None |
||||
|
|
||||
|
try: |
||||
|
# Simple heuristic: if city name contains common subdivision keywords |
||||
|
# in various languages, it might be a subdivision |
||||
|
subdivision_keywords = [ |
||||
|
'Province', 'Region', 'Oblast', 'Governorate', |
||||
|
'District', 'County', 'State', 'Territory', |
||||
|
'استان', 'منطقه', 'ولایت', 'محافظه' |
||||
|
] |
||||
|
|
||||
|
for keyword in subdivision_keywords: |
||||
|
if keyword.lower() in city_name.lower(): |
||||
|
logger.warning( |
||||
|
f"⚠️ City name '{city_name}' at ({lat}, {lon}) " |
||||
|
f"contains subdivision keyword '{keyword}' - treating as region (returning None)" |
||||
|
) |
||||
|
return None |
||||
|
|
||||
|
logger.debug(f"✅ City name '{city_name}' validated for ({lat}, {lon})") |
||||
|
return city_name |
||||
|
|
||||
|
except Exception as e: |
||||
|
logger.error(f"❌ Error validating city name for coordinates ({lat}, {lon}): {str(e)}") |
||||
|
return city_name # Return as-is on error |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get location information (city, country) based on geographic coordinates using reverse geocoding", |
||||
|
manual_parameters=[ |
||||
|
openapi.Parameter( |
||||
|
'lat', |
||||
|
openapi.IN_QUERY, |
||||
|
description="Latitude coordinate (-90 to 90)", |
||||
|
type=openapi.TYPE_NUMBER, |
||||
|
required=True |
||||
|
), |
||||
|
openapi.Parameter( |
||||
|
'lon', |
||||
|
openapi.IN_QUERY, |
||||
|
description="Longitude coordinate (-180 to 180)", |
||||
|
type=openapi.TYPE_NUMBER, |
||||
|
required=True |
||||
|
), |
||||
|
], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="Location information", |
||||
|
schema=ReverseGeolocationResponseSerializer() |
||||
|
), |
||||
|
400: openapi.Response( |
||||
|
description="Invalid or missing coordinates" |
||||
|
), |
||||
|
404: openapi.Response( |
||||
|
description="No location found for the given coordinates" |
||||
|
), |
||||
|
500: openapi.Response( |
||||
|
description="Internal server error" |
||||
|
) |
||||
|
}, |
||||
|
tags=['account'] |
||||
|
) |
||||
|
def get(self, request): |
||||
|
"""Get location info from coordinates""" |
||||
|
# Validate query parameters |
||||
|
serializer = ReverseGeolocationSerializer(data=request.query_params) |
||||
|
|
||||
|
if not serializer.is_valid(): |
||||
|
return Response( |
||||
|
{ |
||||
|
'error': 'Invalid coordinates', |
||||
|
'details': serializer.errors |
||||
|
}, |
||||
|
status=status.HTTP_400_BAD_REQUEST |
||||
|
) |
||||
|
|
||||
|
lat = serializer.validated_data['lat'] |
||||
|
lon = serializer.validated_data['lon'] |
||||
|
|
||||
|
# Log the coordinates for debugging |
||||
|
logger.info(f"Reverse geocoding for coordinates: ({lat}, {lon})") |
||||
|
|
||||
|
# Get location data using the existing function from city_detection_ip.py |
||||
|
location_data = get_location_by_coordinates(lat, lon) |
||||
|
|
||||
|
if not location_data or location_data.get('status') != 'success': |
||||
|
return Response( |
||||
|
{ |
||||
|
'error': 'Could not find location data for these coordinates', |
||||
|
'latitude': lat, |
||||
|
'longitude': lon |
||||
|
}, |
||||
|
status=status.HTTP_404_NOT_FOUND |
||||
|
) |
||||
|
|
||||
|
# Validate city name to ensure it's not a subdivision (region) |
||||
|
city_name = location_data.get('city') |
||||
|
validated_city = self.validate_city_name_from_coordinates(lat, lon, city_name) |
||||
|
|
||||
|
# Format response |
||||
|
response_data = { |
||||
|
'latitude': lat, |
||||
|
'longitude': lon, |
||||
|
'city': validated_city, |
||||
|
'country': None, # GeoNames only returns country_code |
||||
|
'country_code': location_data.get('countryCode'), |
||||
|
'accuracy_radius': None, |
||||
|
'time_zone': None, |
||||
|
'postal_code': None, |
||||
|
} |
||||
|
|
||||
|
logger.info(f"Successfully found location for coordinates ({lat}, {lon}): {response_data.get('city')}, {response_data.get('country_code')}") |
||||
|
|
||||
|
return Response(response_data, status=status.HTTP_200_OK) |
||||
@ -1,3 +1,109 @@ |
|||||
from django.contrib import admin |
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin |
||||
|
from unfold.decorators import display |
||||
|
from django.utils.html import format_html |
||||
|
|
||||
# Register your models here. |
|
||||
|
|
||||
|
from filer.models.thumbnailoptionmodels import ThumbnailOption |
||||
|
# from filer.admin.thumbnailoptionmodels import ThumbnailOptionAdmin as OriginalThumbnailOptionAdmin |
||||
|
|
||||
|
from .models import Comment, AppVersion |
||||
|
|
||||
|
|
||||
|
admin.site.unregister(ThumbnailOption) |
||||
|
|
||||
|
@admin.register(ThumbnailOption) |
||||
|
class ThumbnailOptionAdmin(ModelAdmin): |
||||
|
list_display = ['name', 'dimensions_display', 'crop', 'upscale', 'preview'] |
||||
|
list_filter = ['crop', 'upscale'] |
||||
|
search_fields = ['name'] |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('name', 'width', 'height', 'crop', 'upscale'), |
||||
|
'classes': ('unfold-fieldset',), |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
@display(description=_("Dimensions")) |
||||
|
def dimensions_display(self, obj): |
||||
|
return f"{obj.width} × {obj.height}" |
||||
|
|
||||
|
@display(description=_("Preview")) |
||||
|
def preview(self, obj): |
||||
|
# ایجاد یک نمایش بصری از ابعاد تصویر بندانگشتی |
||||
|
width_percent = min(100, obj.width / 10) # محدود کردن عرض به حداکثر 100% |
||||
|
height_px = min(50, obj.height / 5) # محدود کردن ارتفاع به حداکثر 50px |
||||
|
|
||||
|
return format_html( |
||||
|
'<div style="width:{}%; height:{}px; background-color:#e0e0e0; border:1px solid #ccc; display:flex; align-items:center; justify-content:center;">' |
||||
|
'<span style="font-size:10px; color:#666;">{} × {}</span>' |
||||
|
'</div>', |
||||
|
width_percent, height_px, obj.width, obj.height |
||||
|
) |
||||
|
|
||||
|
# اضافه کردن فیلتر سلسله مراتبی برای نام |
||||
|
def changelist_view(self, request, extra_context=None): |
||||
|
# گرفتن حرف اول از پارامتر URL |
||||
|
first_letter = request.GET.get('first_letter', '') |
||||
|
|
||||
|
# ایجاد لیست حروف الفبا |
||||
|
alphabet = [chr(i) for i in range(ord('A'), ord('Z')+1)] |
||||
|
|
||||
|
# اضافه کردن به context |
||||
|
if extra_context is None: |
||||
|
extra_context = {} |
||||
|
|
||||
|
extra_context['alphabet'] = alphabet |
||||
|
extra_context['selected_letter'] = first_letter |
||||
|
|
||||
|
# اعمال فیلتر به queryset اگر حرف انتخاب شده باشد |
||||
|
if first_letter: |
||||
|
original_get_queryset = self.get_queryset |
||||
|
|
||||
|
def filtered_queryset(request): |
||||
|
qs = original_get_queryset(request) |
||||
|
if first_letter == '0-9': |
||||
|
return qs.filter(name__regex=r'^[0-9]') |
||||
|
return qs.filter(name__istartswith=first_letter) |
||||
|
|
||||
|
self.get_queryset = filtered_queryset |
||||
|
|
||||
|
return super().changelist_view(request, extra_context=extra_context) |
||||
|
|
||||
|
|
||||
|
from utils.admin import project_admin_site, dovoodi_admin_site |
||||
|
project_admin_site.register(ThumbnailOption, ThumbnailOptionAdmin) |
||||
|
dovoodi_admin_site.register(ThumbnailOption, ThumbnailOptionAdmin) |
||||
|
|
||||
|
|
||||
|
class CommentAdmin(ModelAdmin): |
||||
|
list_display = [ |
||||
|
'user_fullname', |
||||
|
'language', |
||||
|
'order', |
||||
|
'created_at', |
||||
|
] |
||||
|
search_fields = ['user_fullname', 'comment_text'] |
||||
|
list_filter = ['language', 'created_at'] |
||||
|
ordering = ['order', '-created_at'] |
||||
|
|
||||
|
|
||||
|
class AppVersionAdmin(ModelAdmin): |
||||
|
list_display = [ |
||||
|
'version', |
||||
|
'app_type', |
||||
|
'is_active', |
||||
|
'created_at', |
||||
|
] |
||||
|
search_fields = ['version', 'description'] |
||||
|
list_filter = ['app_type', 'is_active', 'created_at'] |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
|
||||
|
# Register models with both admin sites |
||||
|
project_admin_site.register(Comment, CommentAdmin) |
||||
|
project_admin_site.register(AppVersion, AppVersionAdmin) |
||||
|
dovoodi_admin_site.register(Comment, CommentAdmin) |
||||
|
dovoodi_admin_site.register(AppVersion, AppVersionAdmin) |
||||
@ -0,0 +1,42 @@ |
|||||
|
from functools import wraps |
||||
|
from django.http import HttpResponseForbidden |
||||
|
from django.contrib.auth.models import AnonymousUser |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
|
||||
|
|
||||
|
def swagger_auth_required(view_func): |
||||
|
""" |
||||
|
Decorator that requires either admin authentication or valid swagger token |
||||
|
""" |
||||
|
@csrf_exempt |
||||
|
@wraps(view_func) |
||||
|
def _wrapped_view(request, *args, **kwargs): |
||||
|
# Check if user is admin |
||||
|
if request.user and request.user.is_authenticated and request.user.is_staff: |
||||
|
return view_func(request, *args, **kwargs) |
||||
|
|
||||
|
# Check swagger token in session |
||||
|
swagger_token = request.session.get('swagger_token') |
||||
|
if swagger_token: |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=swagger_token) |
||||
|
if token_obj.user.is_active: |
||||
|
return view_func(request, *args, **kwargs) |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
# Check Authorization header |
||||
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '') |
||||
|
if auth_header.startswith('Token '): |
||||
|
token = auth_header.split(' ')[1] |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
if token_obj.user.is_active: |
||||
|
return view_func(request, *args, **kwargs) |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
return HttpResponseForbidden("Access denied. Admin authentication or valid token required.") |
||||
|
|
||||
|
return _wrapped_view |
||||
@ -0,0 +1,31 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-09 16:27 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='Comment', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('user_avatar', models.ImageField(blank=True, null=True, upload_to='comments/avatars/%Y/%m/', verbose_name='User Avatar')), |
||||
|
('user_fullname', models.CharField(help_text='Full name of the user who made the comment', max_length=255, verbose_name='User Full Name')), |
||||
|
('user_slogan', models.CharField(blank=True, help_text='User slogan or bio', max_length=500, null=True, verbose_name='User Slogan')), |
||||
|
('comment_text', models.TextField(help_text='The actual comment content', verbose_name='Comment Text')), |
||||
|
('order', models.PositiveIntegerField(default=0, help_text='Order for sorting comments', verbose_name='Order')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Comment', |
||||
|
'verbose_name_plural': 'Comments', |
||||
|
'ordering': ['order', '-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,42 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-11 12:17 |
||||
|
|
||||
|
import dj_language.field |
||||
|
import django.core.validators |
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('dj_language', '0002_auto_20220120_1344'), |
||||
|
('api', '0001_initial'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='AppVersion', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('version', models.CharField(help_text='Application version in format X.Y.Z (e.g., 1.0.0)', max_length=20, unique=True, validators=[django.core.validators.RegexValidator(message='Version must be in format X.Y.Z (e.g., 1.0.0)', regex='^\\d+\\.\\d+\\.\\d+$')], verbose_name='Version')), |
||||
|
('apk_file', models.FileField(help_text='Application APK file', upload_to='app_versions/', verbose_name='APK File')), |
||||
|
('description', models.TextField(blank=True, help_text='Release notes and changes for this version', verbose_name='Description')), |
||||
|
('app_type', models.CharField(choices=[('google_play', 'Google Play'), ('app_store', 'App Store')], default='google_play', help_text='App distribution platform', max_length=20, verbose_name='App Type')), |
||||
|
('app_store_downloads', models.PositiveBigIntegerField(default=0, help_text='Total number of downloads on Apple App Store', verbose_name='App Store Downloads')), |
||||
|
('google_play_downloads', models.PositiveBigIntegerField(default=0, help_text='Total number of downloads on Google Play', verbose_name='Google Play Downloads')), |
||||
|
('is_active', models.BooleanField(default=True, help_text='Is this version active?', verbose_name='Active')), |
||||
|
('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': 'App Version', |
||||
|
'verbose_name_plural': 'App Versions', |
||||
|
'ordering': ['-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='comment', |
||||
|
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'), |
||||
|
), |
||||
|
] |
||||
@ -1,3 +1,138 @@ |
|||||
from django.db import models |
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from dj_language.field import LanguageField |
||||
|
from django.core.validators import RegexValidator |
||||
|
|
||||
# Create your models here. |
|
||||
|
|
||||
|
class Comment(models.Model): |
||||
|
""" |
||||
|
Comment model that stores user information directly |
||||
|
""" |
||||
|
# User information |
||||
|
user_avatar = models.ImageField( |
||||
|
upload_to='comments/avatars/%Y/%m/', |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('User Avatar') |
||||
|
) |
||||
|
user_fullname = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_('User Full Name'), |
||||
|
help_text=_('Full name of the user who made the comment') |
||||
|
) |
||||
|
user_slogan = models.CharField( |
||||
|
max_length=500, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('User Slogan'), |
||||
|
help_text=_('User slogan or bio') |
||||
|
) |
||||
|
|
||||
|
# Comment content |
||||
|
comment_text = models.TextField( |
||||
|
verbose_name=_('Comment Text'), |
||||
|
help_text=_('The actual comment content') |
||||
|
) |
||||
|
language = LanguageField(null=True) |
||||
|
# Ordering and timestamps |
||||
|
order = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Order'), |
||||
|
help_text=_('Order for sorting comments') |
||||
|
) |
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
ordering = ['order', '-created_at'] |
||||
|
verbose_name = _('Comment') |
||||
|
verbose_name_plural = _('Comments') |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.user_fullname}: {self.comment_text[:50]}..." |
||||
|
|
||||
|
|
||||
|
class AppVersion(models.Model): |
||||
|
""" |
||||
|
Model for storing app versions with APK files and descriptions |
||||
|
""" |
||||
|
class AppType(models.TextChoices): |
||||
|
GOOGLE_PLAY = 'google_play', _('Google Play') |
||||
|
APP_STORE = 'app_store', _('App Store') |
||||
|
version = models.CharField( |
||||
|
max_length=20, |
||||
|
unique=True, |
||||
|
validators=[ |
||||
|
RegexValidator( |
||||
|
regex=r'^\d+\.\d+\.\d+$', |
||||
|
message='Version must be in format X.Y.Z (e.g., 1.0.0)' |
||||
|
) |
||||
|
], |
||||
|
verbose_name=_('Version'), |
||||
|
help_text=_('Application version in format X.Y.Z (e.g., 1.0.0)') |
||||
|
) |
||||
|
|
||||
|
apk_file = models.FileField( |
||||
|
upload_to='app_versions/', |
||||
|
verbose_name=_('APK File'), |
||||
|
help_text=_('Application APK file') |
||||
|
) |
||||
|
|
||||
|
description = models.TextField( |
||||
|
verbose_name=_('Description'), |
||||
|
help_text=_('Release notes and changes for this version'), |
||||
|
blank=True |
||||
|
) |
||||
|
|
||||
|
app_type = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=AppType.choices, |
||||
|
default=AppType.GOOGLE_PLAY, |
||||
|
verbose_name=_('App Type'), |
||||
|
help_text=_('App distribution platform') |
||||
|
) |
||||
|
|
||||
|
app_store_downloads = models.PositiveBigIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('App Store Downloads'), |
||||
|
help_text=_('Total number of downloads on Apple App Store') |
||||
|
) |
||||
|
|
||||
|
google_play_downloads = models.PositiveBigIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Google Play Downloads'), |
||||
|
help_text=_('Total number of downloads on Google Play') |
||||
|
) |
||||
|
|
||||
|
is_active = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_('Active'), |
||||
|
help_text=_('Is this version active?') |
||||
|
) |
||||
|
|
||||
|
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: |
||||
|
verbose_name = _('App Version') |
||||
|
verbose_name_plural = _('App Versions') |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f'Version {self.version}' |
||||
|
|
||||
|
@classmethod |
||||
|
def get_latest_active(cls): |
||||
|
""" |
||||
|
Get the latest active version |
||||
|
""" |
||||
|
return cls.objects.filter(is_active=True).order_by('-created_at').first() |
||||
@ -0,0 +1,60 @@ |
|||||
|
from rest_framework import permissions |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from django.contrib.auth.models import AnonymousUser |
||||
|
|
||||
|
|
||||
|
class SwaggerTokenPermission(permissions.BasePermission): |
||||
|
""" |
||||
|
Custom permission for Swagger that allows access to authenticated users via token |
||||
|
or admin users via session authentication |
||||
|
""" |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
# Check if user is admin (for session-based access) |
||||
|
if request.user and request.user.is_authenticated and request.user.is_staff: |
||||
|
return True |
||||
|
|
||||
|
# Check for token in session (from our custom auth system) |
||||
|
swagger_token = request.session.get('swagger_token') |
||||
|
if swagger_token: |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=swagger_token) |
||||
|
if token_obj.user.is_active: |
||||
|
return True |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
# Check for Authorization header |
||||
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '') |
||||
|
if auth_header.startswith('Token '): |
||||
|
token = auth_header.split(' ')[1] |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
if token_obj.user.is_active: |
||||
|
return True |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
return False |
||||
|
|
||||
|
|
||||
|
class IsAdminOrSwaggerToken(permissions.BasePermission): |
||||
|
""" |
||||
|
Permission that allows access to admin users or users with valid swagger token |
||||
|
""" |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
# Allow admin users |
||||
|
if request.user and request.user.is_authenticated and request.user.is_staff: |
||||
|
return True |
||||
|
|
||||
|
# Check swagger token in session |
||||
|
swagger_token = request.session.get('swagger_token') |
||||
|
if swagger_token: |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=swagger_token) |
||||
|
return token_obj.user.is_active |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
return False |
||||
@ -0,0 +1,54 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from utils import FileFieldSerializer |
||||
|
from .models import Comment, AppVersion |
||||
|
|
||||
|
|
||||
|
class CommentSerializer(serializers.ModelSerializer): |
||||
|
""" |
||||
|
Serializer for Comment model with proper file field serialization for avatar |
||||
|
""" |
||||
|
user_avatar = FileFieldSerializer(required=False, allow_null=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = Comment |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'user_avatar', |
||||
|
'user_fullname', |
||||
|
'user_slogan', |
||||
|
'comment_text', |
||||
|
'order', |
||||
|
'created_at' |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at'] |
||||
|
|
||||
|
def validate_user_fullname(self, value): |
||||
|
if not value or not value.strip(): |
||||
|
raise serializers.ValidationError("User full name is required.") |
||||
|
return value |
||||
|
|
||||
|
def validate_comment_text(self, value): |
||||
|
if not value or not value.strip(): |
||||
|
raise serializers.ValidationError("Comment text is required.") |
||||
|
return value |
||||
|
|
||||
|
|
||||
|
|
||||
|
class AppVersionSerializer(serializers.ModelSerializer): |
||||
|
apk_file = FileFieldSerializer() |
||||
|
|
||||
|
class Meta: |
||||
|
model = AppVersion |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'version', |
||||
|
'apk_file', |
||||
|
'description', |
||||
|
'app_type', |
||||
|
'app_store_downloads', |
||||
|
'google_play_downloads', |
||||
|
'is_active', |
||||
|
'created_at', |
||||
|
'updated_at', |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at', 'updated_at'] |
||||
@ -1,10 +1,13 @@ |
|||||
|
|
||||
from django.urls import path |
|
||||
from .views import HomeView, CountryView |
|
||||
|
from django.urls import path,include |
||||
|
from .views import HomeView, CountryView, CommentListAPIView |
||||
|
from .views.api_views import AppVersionListAPIView |
||||
|
|
||||
|
|
||||
|
|
||||
urlpatterns = [ |
urlpatterns = [ |
||||
path('', HomeView.as_view()), |
path('', HomeView.as_view()), |
||||
path('countries/', CountryView.as_view()), |
path('countries/', CountryView.as_view()), |
||||
|
path('comments/', CommentListAPIView.as_view(), name='comment-list'), |
||||
|
path('app-versions/', AppVersionListAPIView.as_view(), name='appversion-list'), |
||||
] |
] |
||||
@ -1,42 +1,2 @@ |
|||||
import random |
|
||||
from rest_framework.generics import GenericAPIView |
|
||||
from rest_framework.response import Response |
|
||||
from rest_framework import serializers |
|
||||
|
|
||||
from rest_framework.authtoken.models import Token |
|
||||
from apps.account.models import User |
|
||||
|
|
||||
class HomeSerializer(serializers.Serializer): |
|
||||
token = serializers.CharField() |
|
||||
|
|
||||
from utils.countries import countries |
|
||||
|
|
||||
|
|
||||
# test class generate token |
|
||||
class HomeView(GenericAPIView): |
|
||||
serializer_class = HomeSerializer |
|
||||
|
|
||||
def get(self, request): |
|
||||
emails = ["zahra@gmail.com", "john.doe@example.com", "alice@example.com"] |
|
||||
phone_numbers = ["09012037621", "09012037615", "09012045432"] |
|
||||
fullnames = ["Alireza", "John Doe", "Alice Smith"] |
|
||||
# انتخاب رندوم از هر لیست |
|
||||
email = random.choice(emails) |
|
||||
phone_number = random.choice(phone_numbers) |
|
||||
fullname = random.choice(fullnames) |
|
||||
# ساخت کاربر جدید |
|
||||
user = User.objects.create( |
|
||||
email=email, |
|
||||
phone_number=phone_number, |
|
||||
fullname=fullname, |
|
||||
) |
|
||||
# ایجاد توکن برای کاربر |
|
||||
token, created = Token.objects.get_or_create(user=user) |
|
||||
|
|
||||
return Response({'token': token.key}) |
|
||||
|
|
||||
class CountryView(GenericAPIView): |
|
||||
|
|
||||
def get(self, request): |
|
||||
return Response(countries, status=200) |
|
||||
|
|
||||
|
# Legacy views - moved to views/api_views.py for better organization |
||||
|
from .views.api_views import HomeView, CountryView, CommentListAPIView |
||||
@ -0,0 +1,16 @@ |
|||||
|
# API Views Package |
||||
|
# This package contains all API-related views organized by functionality |
||||
|
|
||||
|
from .api_views import HomeView, CountryView, CommentListAPIView |
||||
|
from .documentation import CustomAPIDocumentationView |
||||
|
from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth |
||||
|
|
||||
|
__all__ = [ |
||||
|
'HomeView', |
||||
|
'CountryView', |
||||
|
'CommentListAPIView', |
||||
|
'CustomAPIDocumentationView', |
||||
|
'CustomSwaggerView', |
||||
|
'SwaggerTokenAuthView', |
||||
|
'clear_swagger_auth', |
||||
|
] |
||||
@ -0,0 +1,100 @@ |
|||||
|
import random |
||||
|
from rest_framework.generics import GenericAPIView, ListAPIView |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework import serializers |
||||
|
from rest_framework.permissions import AllowAny |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from drf_yasg import openapi |
||||
|
|
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from apps.account.models import User |
||||
|
from apps.api.models import Comment, AppVersion |
||||
|
from apps.api.serializers import CommentSerializer, AppVersionSerializer |
||||
|
|
||||
|
class HomeSerializer(serializers.Serializer): |
||||
|
token = serializers.CharField() |
||||
|
|
||||
|
from utils.countries import countries |
||||
|
|
||||
|
|
||||
|
# test class generate token |
||||
|
class HomeView(GenericAPIView): |
||||
|
serializer_class = HomeSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Health check and token test endpoint. Optionally reads BUILD_NUMBER from headers.", |
||||
|
manual_parameters=[ |
||||
|
openapi.Parameter( |
||||
|
name='BUILD_NUMBER', |
||||
|
in_=openapi.IN_HEADER, |
||||
|
description='Client build number', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
) |
||||
|
], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="OK", |
||||
|
schema=HomeSerializer() |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request): |
||||
|
# Get build_number from headers |
||||
|
build_number = request.META.get('HTTP_BUILD_NUMBER') |
||||
|
|
||||
|
# Print the build_number |
||||
|
print(f"Build Number: {build_number}") |
||||
|
|
||||
|
return Response({'token': "ok", 'build_number': build_number}) |
||||
|
|
||||
|
class CountryView(GenericAPIView): |
||||
|
@swagger_auto_schema( |
||||
|
operation_description="List of countries with dialing codes and flags", |
||||
|
responses={200: openapi.Response(description="Countries list")} |
||||
|
) |
||||
|
def get(self, request): |
||||
|
return Response(countries, status=200) |
||||
|
|
||||
|
|
||||
|
class CommentListAPIView(ListAPIView): |
||||
|
""" |
||||
|
API view to list comments ordered by order field and creation date |
||||
|
""" |
||||
|
queryset = Comment.objects.all() |
||||
|
serializer_class = CommentSerializer |
||||
|
permission_classes = [AllowAny] |
||||
|
ordering = ['order', '-created_at'] # Order by order field first, then by newest |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="List comments ordered by 'order' then '-created_at'", |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of comments", |
||||
|
schema=CommentSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
queryset = super().get_queryset() |
||||
|
return queryset.order_by('order', '-created_at') |
||||
|
|
||||
|
|
||||
|
class AppVersionListAPIView(ListAPIView): |
||||
|
queryset = AppVersion.objects.all().order_by('-created_at') |
||||
|
serializer_class = AppVersionSerializer |
||||
|
permission_classes = [AllowAny] |
||||
|
@swagger_auto_schema( |
||||
|
operation_description="List all app versions with fields.", |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of app versions", |
||||
|
schema=AppVersionSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
1061
apps/api/views/documentation.py
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,83 @@ |
|||||
|
from django.shortcuts import render, redirect |
||||
|
from django.views import View |
||||
|
from django.contrib import messages |
||||
|
from django.contrib.admin.views.decorators import staff_member_required |
||||
|
from django.utils.decorators import method_decorator |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from django.urls import reverse |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
|
||||
|
@method_decorator([staff_member_required, csrf_exempt], name='dispatch') |
||||
|
class CustomSwaggerView(View): |
||||
|
""" |
||||
|
Custom Swagger UI view with authentication banner |
||||
|
Requires admin login to access |
||||
|
""" |
||||
|
def get(self, request): |
||||
|
# Generate dynamic swagger spec URL based on current language |
||||
|
try: |
||||
|
swagger_spec_url = reverse('schema-json', kwargs={'format': '.json'}) |
||||
|
except: |
||||
|
# Fallback to hardcoded URL if reverse fails |
||||
|
swagger_spec_url = '/en/swagger.json' |
||||
|
|
||||
|
context = { |
||||
|
'swagger_spec_url': swagger_spec_url, |
||||
|
'request': request, |
||||
|
} |
||||
|
return render(request, 'swagger/ui.html', context) |
||||
|
|
||||
|
@method_decorator(staff_member_required, name='dispatch') |
||||
|
class SwaggerTokenAuthView(View): |
||||
|
""" |
||||
|
Token authentication management for Swagger |
||||
|
""" |
||||
|
def get(self, request): |
||||
|
context = { |
||||
|
'current_token': request.session.get('swagger_token'), |
||||
|
'user_info': request.session.get('swagger_user_info'), |
||||
|
} |
||||
|
return render(request, 'swagger/auth.html', context) |
||||
|
|
||||
|
def post(self, request): |
||||
|
token = request.POST.get('token', '').strip() |
||||
|
|
||||
|
if not token or len(token) != 40: |
||||
|
messages.error(request, 'Token must be exactly 40 characters long') |
||||
|
return redirect('swagger-token-auth') |
||||
|
|
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
user = token_obj.user |
||||
|
|
||||
|
if not user.is_active: |
||||
|
messages.error(request, 'User account is not active') |
||||
|
return redirect('swagger-token-auth') |
||||
|
|
||||
|
request.session['swagger_token'] = token |
||||
|
request.session['swagger_user_info'] = { |
||||
|
'id': user.id, |
||||
|
'email': user.email, |
||||
|
'fullname': getattr(user, 'fullname', user.email), |
||||
|
'is_staff': user.is_staff, |
||||
|
'is_superuser': user.is_superuser, |
||||
|
'user_type': 'User' |
||||
|
} |
||||
|
|
||||
|
messages.success(request, f'Successfully authenticated as {user.email}') |
||||
|
return redirect('schema-swagger-ui') |
||||
|
|
||||
|
except Token.DoesNotExist: |
||||
|
messages.error(request, 'Invalid token') |
||||
|
return redirect('swagger-token-auth') |
||||
|
|
||||
|
@staff_member_required |
||||
|
def clear_swagger_auth(request): |
||||
|
"""Clear swagger authentication from session""" |
||||
|
if 'swagger_token' in request.session: |
||||
|
del request.session['swagger_token'] |
||||
|
if 'swagger_user_info' in request.session: |
||||
|
del request.session['swagger_user_info'] |
||||
|
|
||||
|
messages.success(request, 'Successfully logged out from Swagger') |
||||
|
return redirect('swagger-token-auth') |
||||
@ -0,0 +1,314 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from django.urls import reverse |
||||
|
from django.utils.html import format_html |
||||
|
from django.db import models |
||||
|
from ajaxdatatable.admin import AjaxDatatable |
||||
|
from unfold.admin import ModelAdmin, StackedInline, TabularInline |
||||
|
from django.contrib.admin import SimpleListFilter |
||||
|
from unfold.widgets import UnfoldAdminSelectWidget |
||||
|
from django.shortcuts import get_object_or_404, redirect, render |
||||
|
|
||||
|
from unfold.decorators import display, action |
||||
|
from django import forms |
||||
|
from django.urls import path, reverse_lazy |
||||
|
|
||||
|
from utils.admin import dovoodi_admin_site |
||||
|
from unfold.sections import TableSection |
||||
|
|
||||
|
from apps.article.models import ( |
||||
|
ArticleCategory, |
||||
|
ArticleCollection, |
||||
|
PinnedArticleCollection, |
||||
|
MiddleArticleCollection, |
||||
|
Article, |
||||
|
ArticleInCollection, |
||||
|
ArticleContent, |
||||
|
ContentPart, |
||||
|
TextSection |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class ArticleInCollectionInline(TabularInline): |
||||
|
model = ArticleInCollection |
||||
|
extra = 1 |
||||
|
autocomplete_fields = ('article',) |
||||
|
fields = ('article', 'order') |
||||
|
ordering = ('order',) |
||||
|
verbose_name = _('Article') |
||||
|
verbose_name_plural = _('Articles') |
||||
|
tab = True |
||||
|
|
||||
|
|
||||
|
class TextSectionInline(TabularInline): |
||||
|
model = TextSection |
||||
|
extra = 1 |
||||
|
fields = ('arabic_text', 'translation', 'order') |
||||
|
ordering = ('order',) |
||||
|
verbose_name = _('Text Section') |
||||
|
verbose_name_plural = _('Text Sections') |
||||
|
|
||||
|
class ContentPartInline(StackedInline): |
||||
|
model = ContentPart |
||||
|
extra = 1 |
||||
|
fields = ('order',) |
||||
|
ordering = ('order',) |
||||
|
verbose_name = _('Content Part') |
||||
|
verbose_name_plural = _('Content Parts') |
||||
|
tab = True |
||||
|
|
||||
|
|
||||
|
class ArticleContentInline(StackedInline): |
||||
|
model = ArticleContent |
||||
|
extra = 1 |
||||
|
fields = ('title', 'content', 'priority', 'status') |
||||
|
ordering = ('priority',) |
||||
|
verbose_name = _('Article Content') |
||||
|
verbose_name_plural = _('Article Contents') |
||||
|
tab = True |
||||
|
|
||||
|
|
||||
|
class ArticleCollectionAdminBase(ModelAdmin): |
||||
|
list_display = ('get_title', 'get_display_position', 'status', 'order', 'count_articles') |
||||
|
list_filter = ('status', 'order') |
||||
|
search_fields = ('title',) |
||||
|
ordering = ('order',) |
||||
|
list_filter_submit = True |
||||
|
warn_unsaved_form = True |
||||
|
change_form_show_cancel_button = True |
||||
|
inlines = [ArticleInCollectionInline] |
||||
|
|
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('title', 'summary', 'thumbnail', 'status', 'pin_top', 'order') |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
exclude = ('display_position',) |
||||
|
|
||||
|
@display(description=_('Title')) |
||||
|
def get_title(self, obj): |
||||
|
return str(obj.title) |
||||
|
|
||||
|
@display(description=_('Display Position')) |
||||
|
def get_display_position(self, obj): |
||||
|
if obj.display_position == ArticleCollection.DisplayPosition.PINNED: |
||||
|
return format_html('<span style="color: #0066cc; font-weight: bold;">📌 Pinned (Top)</span>') |
||||
|
else: |
||||
|
return format_html('<span style="color: #666;">📋 Regular (Middle)</span>') |
||||
|
|
||||
|
@display(description=_('Number of Articles')) |
||||
|
def count_articles(self, obj): |
||||
|
count = obj.related_articles.count() |
||||
|
if count > 0: |
||||
|
url = reverse('admin:article_article_changelist') + f'?collections__id__exact={obj.id}' |
||||
|
return format_html('<a href="{}">{}</a>', url, count) |
||||
|
return count |
||||
|
|
||||
|
|
||||
|
class PinnedArticleCollectionForm(forms.ModelForm): |
||||
|
class Meta: |
||||
|
model = PinnedArticleCollection |
||||
|
exclude = ('slug',) |
||||
|
|
||||
|
def __init__(self, *args, **kwargs): |
||||
|
super().__init__(*args, **kwargs) |
||||
|
self.fields['thumbnail'].required = True |
||||
|
|
||||
|
|
||||
|
class PinnedArticleCollectionAdmin(ArticleCollectionAdminBase): |
||||
|
form = PinnedArticleCollectionForm |
||||
|
|
||||
|
def get_queryset(self, request): |
||||
|
return super().get_queryset(request).filter(display_position=ArticleCollection.DisplayPosition.PINNED) |
||||
|
|
||||
|
def save_model(self, request, obj, form, change): |
||||
|
obj.display_position = ArticleCollection.DisplayPosition.PINNED |
||||
|
super().save_model(request, obj, form, change) |
||||
|
|
||||
|
|
||||
|
@display(description=_('Title')) |
||||
|
def get_title(self, obj): |
||||
|
from django.templatetags.static import static |
||||
|
thumbnail_path = obj.thumbnail.url if obj.thumbnail else None |
||||
|
return obj.title |
||||
|
# Uncomment below to show thumbnail in the title column |
||||
|
# return [ |
||||
|
# obj.title, |
||||
|
# None, |
||||
|
# None, |
||||
|
# { |
||||
|
# "path": thumbnail_path, |
||||
|
# "height": 30, |
||||
|
# "width": 50, |
||||
|
# "borderless": True, |
||||
|
# # "squared": True, |
||||
|
# }, |
||||
|
# ] |
||||
|
|
||||
|
|
||||
|
class MiddleArticleCollectionAdmin(ArticleCollectionAdminBase): |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('title', 'status', 'pin_top', 'order') |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
def get_queryset(self, request): |
||||
|
return super().get_queryset(request).filter(display_position=ArticleCollection.DisplayPosition.MIDDLE) |
||||
|
|
||||
|
def save_model(self, request, obj, form, change): |
||||
|
obj.display_position = ArticleCollection.DisplayPosition.MIDDLE |
||||
|
super().save_model(request, obj, form, change) |
||||
|
|
||||
|
|
||||
|
class ArticleCategoryAdmin(ModelAdmin): |
||||
|
list_display = ('title', 'slug', 'status', 'order', 'count_articles', 'created_at') |
||||
|
list_filter = ('status', 'created_at', 'updated_at') |
||||
|
search_fields = ('title', 'slug') |
||||
|
|
||||
|
|
||||
|
@admin.display(description=_('Number of Articles')) |
||||
|
def count_articles(self, obj): |
||||
|
count = obj.articles.count() |
||||
|
if count > 0: |
||||
|
url = reverse('admin:article_article_changelist') + f'?categories__id__exact={obj.id}' |
||||
|
return format_html('<a href="{}">{}</a>', url, count) |
||||
|
return count |
||||
|
|
||||
|
def get_form(self, request, obj=None, change=False, **kwargs): |
||||
|
form = super().get_form(request, obj, change, **kwargs) |
||||
|
if form.base_fields.get('slug'): |
||||
|
form.base_fields['slug'].required = False |
||||
|
return form |
||||
|
|
||||
|
|
||||
|
class ArticleAdmin(ModelAdmin): |
||||
|
|
||||
|
# change_form_before_template = 'article/change_form_before_template.html' |
||||
|
list_display = ('display_header', 'slug', 'status', 'view_count', 'created_at') |
||||
|
list_filter = ('status', 'created_at', 'updated_at') |
||||
|
search_fields = ('title', 'slug', 'description', 'content') |
||||
|
autocomplete_fields = ('categories',) |
||||
|
save_as = True |
||||
|
search_help_text = _("Search by title, slug, description or content") |
||||
|
search_fields_placeholder = _("Search articles") |
||||
|
# inlines = [ArticleContentInline] |
||||
|
actions_row = [ |
||||
|
"action_contents", |
||||
|
] |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('title', 'slug', 'description', 'thumbnail', 'categories') |
||||
|
}), |
||||
|
(_('File'), { |
||||
|
'fields': ('article_file',) |
||||
|
}), |
||||
|
(_('Status'), { |
||||
|
'fields': ('status',) |
||||
|
}), |
||||
|
(_('Statistics'), { |
||||
|
'fields': ('view_count',) |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
def get_form(self, request, obj=None, change=False, **kwargs): |
||||
|
form = super().get_form(request, obj, change, **kwargs) |
||||
|
if form.base_fields.get('slug'): |
||||
|
form.base_fields['slug'].required = False |
||||
|
if form.base_fields.get('thumbnail'): |
||||
|
form.base_fields['thumbnail'].required = True |
||||
|
return form |
||||
|
|
||||
|
|
||||
|
@action( |
||||
|
description=_("Contents"), |
||||
|
url_path="actions-row-custom-url", |
||||
|
) |
||||
|
def action_contents(self, request, object_id): |
||||
|
article = get_object_or_404(Article, pk=object_id) |
||||
|
url = reverse('admin:article_articlecontent_changelist') + f'?article__id__exact={article.id}' |
||||
|
return redirect(url) |
||||
|
|
||||
|
@display(description=_("Article"), header=True) |
||||
|
def display_header(self, obj): |
||||
|
from django.templatetags.static import static |
||||
|
|
||||
|
# Get thumbnail image path - use article's thumbnail if available, otherwise use default |
||||
|
thumbnail_path = obj.thumbnail.url if obj.thumbnail else None |
||||
|
|
||||
|
return [ |
||||
|
obj.title, |
||||
|
None, |
||||
|
None, |
||||
|
{ |
||||
|
"path": thumbnail_path, |
||||
|
"height": 30, |
||||
|
"width": 50, |
||||
|
"borderless": True, |
||||
|
# "squared": True, |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
|
||||
|
class ArticleContentAdmin(ModelAdmin): |
||||
|
list_display = ('title', 'article', 'priority', 'status', 'created_at') |
||||
|
list_filter = ('status', 'priority', 'created_at') |
||||
|
search_fields = ('title', 'content') |
||||
|
autocomplete_fields = ('article',) |
||||
|
inlines = [ContentPartInline] |
||||
|
actions_row = [ |
||||
|
"action_parts", |
||||
|
] |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('article', 'title', 'content', 'priority', 'status') |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
def get_changeform_initial_data(self, request): |
||||
|
initial = super().get_changeform_initial_data(request) |
||||
|
if 'article__id__exact' in request.GET: |
||||
|
initial['article'] = request.GET.get('article__id__exact') |
||||
|
return initial |
||||
|
|
||||
|
@action( |
||||
|
description=_("Parts"), |
||||
|
url_path="actions-row-parts-url", |
||||
|
) |
||||
|
def action_parts(self, request, object_id): |
||||
|
content = get_object_or_404(ArticleContent, pk=object_id) |
||||
|
url = reverse('admin:article_contentpart_changelist') + f'?article_content__id__exact={content.id}' |
||||
|
return redirect(url) |
||||
|
|
||||
|
|
||||
|
class ContentPartAdmin(ModelAdmin): |
||||
|
list_display = ('article_content', 'order', 'created_at') |
||||
|
list_filter = ('created_at',) |
||||
|
autocomplete_fields = ('article_content',) |
||||
|
inlines = [TextSectionInline] |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('article_content', 'order') |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
def get_changeform_initial_data(self, request): |
||||
|
initial = super().get_changeform_initial_data(request) |
||||
|
if 'article_content__id__exact' in request.GET: |
||||
|
initial['article_content'] = request.GET.get('article_content__id__exact') |
||||
|
return initial |
||||
|
|
||||
|
|
||||
|
# Register models with admin site |
||||
|
dovoodi_admin_site.register(ArticleCategory, ArticleCategoryAdmin) |
||||
|
dovoodi_admin_site.register(PinnedArticleCollection, PinnedArticleCollectionAdmin) |
||||
|
dovoodi_admin_site.register(MiddleArticleCollection, MiddleArticleCollectionAdmin) |
||||
|
dovoodi_admin_site.register(Article, ArticleAdmin) |
||||
|
dovoodi_admin_site.register(ArticleContent, ArticleContentAdmin) |
||||
|
dovoodi_admin_site.register(ContentPart, ContentPartAdmin) |
||||
@ -0,0 +1,6 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class ArticleConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.article' |
||||
@ -0,0 +1,445 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.utils.text import slugify |
||||
|
from apps.article.models import ( |
||||
|
ArticleCategory, |
||||
|
ArticleCollection, |
||||
|
Article, |
||||
|
ArticleContent, |
||||
|
ContentPart, |
||||
|
TextSection |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = 'Seed article data with Russian content about Imams and Prophets' |
||||
|
|
||||
|
def handle(self, *args, **kwargs): |
||||
|
self.stdout.write(self.style.SUCCESS('Starting to seed article data...')) |
||||
|
|
||||
|
# Create categories |
||||
|
self.stdout.write('Creating categories...') |
||||
|
categories = self.create_categories() |
||||
|
|
||||
|
# Create collections |
||||
|
self.stdout.write('Creating collections...') |
||||
|
collections = self.create_collections() |
||||
|
|
||||
|
# Create articles |
||||
|
self.stdout.write('Creating articles...') |
||||
|
articles = self.create_articles(categories, collections) |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS(f'Successfully created {len(articles)} articles with content!')) |
||||
|
|
||||
|
def create_categories(self): |
||||
|
categories_data = [ |
||||
|
{'title': 'Пророки', 'order': 1}, |
||||
|
{'title': 'Имамы', 'order': 2}, |
||||
|
{'title': 'Жизнь имамов', 'order': 3}, |
||||
|
{'title': 'Исламская история', 'order': 4}, |
||||
|
] |
||||
|
|
||||
|
categories = [] |
||||
|
for cat_data in categories_data: |
||||
|
category, created = ArticleCategory.objects.get_or_create( |
||||
|
title=cat_data['title'], |
||||
|
defaults={ |
||||
|
'slug': slugify(cat_data['title'], allow_unicode=True), |
||||
|
'order': cat_data['order'], |
||||
|
'status': True |
||||
|
} |
||||
|
) |
||||
|
categories.append(category) |
||||
|
if created: |
||||
|
self.stdout.write(f' Created category: {category.title}') |
||||
|
|
||||
|
return categories |
||||
|
|
||||
|
def create_collections(self): |
||||
|
collections_data = [ |
||||
|
{ |
||||
|
'title': 'Избранные статьи об имамах', |
||||
|
'summary': 'Лучшие статьи о жизни и учениях имамов', |
||||
|
'pin_top': True, |
||||
|
'display_position': 'pinned', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Коллекция о пророках', |
||||
|
'summary': 'Статьи о пророках в исламе', |
||||
|
'pin_top': False, |
||||
|
'display_position': 'middle', |
||||
|
'order': 2 |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
collections = [] |
||||
|
for coll_data in collections_data: |
||||
|
collection, created = ArticleCollection.objects.get_or_create( |
||||
|
title=coll_data['title'], |
||||
|
defaults={ |
||||
|
'slug': slugify(coll_data['title'], allow_unicode=True), |
||||
|
'summary': coll_data['summary'], |
||||
|
'pin_top': coll_data['pin_top'], |
||||
|
'display_position': coll_data['display_position'], |
||||
|
'order': coll_data['order'], |
||||
|
'status': True |
||||
|
} |
||||
|
) |
||||
|
collections.append(collection) |
||||
|
if created: |
||||
|
self.stdout.write(f' Created collection: {collection.title}') |
||||
|
|
||||
|
return collections |
||||
|
|
||||
|
def create_articles(self, categories, collections): |
||||
|
articles_data = [ |
||||
|
{ |
||||
|
'title': 'Имам Джавад (мир ему)', |
||||
|
'description': 'Биография девятого имама шиитов, Мухаммада ибн Али аль-Джавада', |
||||
|
'categories': [categories[1]], # Имамы |
||||
|
'collections': [collections[0]], |
||||
|
'content_sections': [ |
||||
|
{ |
||||
|
'title': 'Введение', |
||||
|
'content': 'Имам Мухаммад ибн Али аль-Джавад - девятый имам двенадцати имамов в шиитском исламе.\nОн родился в 195 году хиджры в Медине и стал имамом в очень молодом возрасте.\nЕго жизнь полна уроков мудрости и знания.', |
||||
|
'priority': 1, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ', |
||||
|
'translation': 'Во имя Аллаха, Милостивого, Милосердного', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ وَالصَّلَاةُ وَالسَّلَامُ عَلَىٰ سَيِّدِنَا مُحَمَّدٍ وَآلِهِ الطَّاهِرِينَ', |
||||
|
'translation': 'Хвала Аллаху, Господу миров, и мир и благословение нашему господину Мухаммаду и его пречистому семейству', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Рождение и детство', |
||||
|
'content': 'Имам Джавад родился в 195 году хиджры (811 г. н.э.) в священном городе Медина.\nЕго отцом был имам Али ибн Муса ар-Рида, восьмой имам.\nОн стал имамом в возрасте семи или девяти лет после мученической смерти своего отца.', |
||||
|
'priority': 2, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'وُلِدَ الإِمَامُ مُحَمَّدُ بْنُ عَلِيٍّ الْجَوَادُ فِي الْمَدِينَةِ الْمُنَوَّرَةِ', |
||||
|
'translation': 'Имам Мухаммад ибн Али аль-Джавад родился в благословенной Медине', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'فِي سَنَةِ خَمْسٍ وَتِسْعِينَ وَمِائَةٍ مِنَ الْهِجْرَةِ', |
||||
|
'translation': 'В сто девяносто пятом году хиджры', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'order': 2, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'وَكَانَ عُمْرُهُ عِنْدَ الإِمَامَةِ سَبْعَ سِنِينَ أَوْ تِسْعَ سِنِينَ', |
||||
|
'translation': 'Ему было семь или девять лет, когда он стал имамом', |
||||
|
'order': 1 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Знания и мудрость', |
||||
|
'content': 'Несмотря на свой молодой возраст, имам Джавад проявлял невероятные знания и мудрость.\nОн отвечал на сложные вопросы ученых и удивлял их своей проницательностью.\nЕго называли "аль-Джавад" (щедрый) за его великодушие и знания.', |
||||
|
'priority': 3, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'كَانَ الإِمَامُ الْجَوَادُ آيَةً فِي الْعِلْمِ وَالْحِكْمَةِ', |
||||
|
'translation': 'Имам Джавад был знамением в знании и мудрости', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'وَأَجَابَ عَلَى أَسْئِلَةِ الْعُلَمَاءِ وَهُوَ صَغِيرُ السِّنِّ', |
||||
|
'translation': 'Он отвечал на вопросы ученых в юном возрасте', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Пророк Мухаммад (да благословит его Аллах)', |
||||
|
'description': 'Жизнь и миссия последнего пророка Аллаха, печати пророков', |
||||
|
'categories': [categories[0], categories[3]], # Пророки, Исламская история |
||||
|
'collections': [collections[1]], |
||||
|
'content_sections': [ |
||||
|
{ |
||||
|
'title': 'Введение о Пророке', |
||||
|
'content': 'Мухаммад ибн Абдуллах - последний пророк и посланник Аллаха.\nОн родился в Мекке в 570 году н.э. в племени курайш.\nЕго послание является последним откровением для всего человечества.', |
||||
|
'priority': 1, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'مُحَمَّدٌ رَسُولُ اللَّهِ وَخَاتَمُ النَّبِيِّينَ', |
||||
|
'translation': 'Мухаммад - посланник Аллаха и печать пророков', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'وَمَا أَرْسَلْنَاكَ إِلَّا رَحْمَةً لِّلْعَالَمِينَ', |
||||
|
'translation': 'Мы послали тебя только как милость для миров', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Начало откровения', |
||||
|
'content': 'В возрасте сорока лет пророк Мухаммад получил первое откровение в пещере Хира.\nАнгел Джибриль (Гавриил) явился ему с первыми аятами Корана.\nЭто было началом его пророческой миссии, которая продлилась 23 года.', |
||||
|
'priority': 2, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'اقْرَأْ بِاسْمِ رَبِّكَ الَّذِي خَلَقَ', |
||||
|
'translation': 'Читай во имя Господа твоего, Который сотворил', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'خَلَقَ الْإِنسَانَ مِنْ عَلَقٍ', |
||||
|
'translation': 'Сотворил человека из сгустка', |
||||
|
'order': 2 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'اقْرَأْ وَرَبُّكَ الْأَكْرَمُ', |
||||
|
'translation': 'Читай, ведь твой Господь - Самый великодушный', |
||||
|
'order': 3 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Имам Али (мир ему)', |
||||
|
'description': 'Первый имам и двоюродный брат пророка Мухаммада, врата знания', |
||||
|
'categories': [categories[1], categories[2]], # Имамы, Жизнь имамов |
||||
|
'collections': [collections[0]], |
||||
|
'content_sections': [ |
||||
|
{ |
||||
|
'title': 'Али - врата знания', |
||||
|
'content': 'Имам Али ибн Абу Талиб - первый имам шиитов и четвертый праведный халиф.\nОн был двоюродным братом и зятем пророка Мухаммада.\nПророк сказал о нем: "Я - город знания, а Али - его врата".', |
||||
|
'priority': 1, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'عَلِيٌّ مَعَ الْحَقِّ وَالْحَقُّ مَعَ عَلِيٍّ', |
||||
|
'translation': 'Али с истиной, и истина с Али', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'أَنَا مَدِينَةُ الْعِلْمِ وَعَلِيٌّ بَابُهَا', |
||||
|
'translation': 'Я - город знания, а Али - его врата', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Мудрость имама Али', |
||||
|
'content': 'Имам Али известен своими мудрыми изречениями и наставлениями.\nЕго книга "Нахдж аль-Балага" содержит его проповеди, письма и изречения.\nОн был образцом справедливости, храбрости и знания.', |
||||
|
'priority': 2, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'النَّاسُ أَعْدَاءُ مَا جَهِلُوا', |
||||
|
'translation': 'Люди - враги того, чего они не знают', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'الْعِلْمُ خَيْرُ مِنَ الْمَالِ', |
||||
|
'translation': 'Знание лучше, чем богатство', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'order': 2, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'قِيمَةُ كُلِّ امْرِئٍ مَا يُحْسِنُهُ', |
||||
|
'translation': 'Ценность каждого человека в том, что он хорошо делает', |
||||
|
'order': 1 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Имам Хусейн (мир ему)', |
||||
|
'description': 'Третий имам и внук пророка Мухаммада, мученик Кербелы', |
||||
|
'categories': [categories[1], categories[2]], |
||||
|
'collections': [collections[0]], |
||||
|
'content_sections': [ |
||||
|
{ |
||||
|
'title': 'Жертвоприношение в Кербеле', |
||||
|
'content': 'Имам Хусейн ибн Али - третий имам шиитов и внук пророка Мухаммада.\nОн принял мученичество в Кербеле в 680 году н.э., защищая истину и справедливость.\nЕго жертва стала символом борьбы против тирании и несправедливости.', |
||||
|
'priority': 1, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'إِنِّي لَا أَرَى الْمَوْتَ إِلَّا سَعَادَةً وَالْحَيَاةَ مَعَ الظَّالِمِينَ إِلَّا بَرَمًا', |
||||
|
'translation': 'Я не вижу смерть иначе как счастье, а жизнь с угнетателями - иначе как несчастье', |
||||
|
'order': 1 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Послание Кербелы', |
||||
|
'content': 'Восстание имама Хусейна не было военным восстанием, а духовной революцией.\nОн выступил против несправедливости и коррупции правителя Язида.\nЕго послание остается актуальным для всех поколений: "Если у вас нет религии, то хотя бы будьте свободными".', |
||||
|
'priority': 2, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'هَيْهَاتَ مِنَّا الذِّلَّةُ', |
||||
|
'translation': 'Далеки мы от унижения', |
||||
|
'order': 1 |
||||
|
}, |
||||
|
{ |
||||
|
'arabic_text': 'مَوْتٌ فِي عِزٍّ خَيْرٌ مِنْ حَيَاةٍ فِي ذُلٍّ', |
||||
|
'translation': 'Смерть в достоинстве лучше жизни в унижении', |
||||
|
'order': 2 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Пророк Иса (мир ему)', |
||||
|
'description': 'Иисус, сын Марии, один из великих пророков в исламе', |
||||
|
'categories': [categories[0]], |
||||
|
'collections': [collections[1]], |
||||
|
'content_sections': [ |
||||
|
{ |
||||
|
'title': 'Пророк Иса в исламе', |
||||
|
'content': 'Иса ибн Марьям (Иисус) - один из величайших пророков в исламе.\nОн был рожден чудесным образом от девы Марии по воле Аллаха.\nМусульмане почитают его как пророка и посланника Бога, но не как Бога.', |
||||
|
'priority': 1, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'إِنَّ مَثَلَ عِيسَىٰ عِندَ اللَّهِ كَمَثَلِ آدَمَ ۖ خَلَقَهُ مِن تُرَابٍ ثُمَّ قَالَ لَهُ كُن فَيَكُونُ', |
||||
|
'translation': 'Воистину, Иса перед Аллахом подобен Адаму. Он сотворил его из праха, потом сказал ему: "Будь!" - и тот возник', |
||||
|
'order': 1 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
'title': 'Чудеса пророка Исы', |
||||
|
'content': 'Аллах даровал пророку Исе множество чудес в подтверждение его пророчества.\nОн исцелял больных, воскрешал мертвых и говорил с людьми еще в колыбели.\nВсе эти чудеса происходили по воле и дозволению Аллаха.', |
||||
|
'priority': 2, |
||||
|
'parts': [ |
||||
|
{ |
||||
|
'order': 1, |
||||
|
'text_sections': [ |
||||
|
{ |
||||
|
'arabic_text': 'وَأُبْرِئُ الْأَكْمَهَ وَالْأَبْرَصَ وَأُحْيِي الْمَوْتَىٰ بِإِذْنِ اللَّهِ', |
||||
|
'translation': 'Я исцеляю слепого и прокаженного и оживляю мертвых с дозволения Аллаха', |
||||
|
'order': 1 |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
|
||||
|
articles = [] |
||||
|
for article_data in articles_data: |
||||
|
# Create or get article |
||||
|
article, created = Article.objects.get_or_create( |
||||
|
title=article_data['title'], |
||||
|
defaults={ |
||||
|
'slug': slugify(article_data['title'], allow_unicode=True), |
||||
|
'description': article_data['description'], |
||||
|
'status': True |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
if created: |
||||
|
self.stdout.write(f' Created article: {article.title}') |
||||
|
|
||||
|
# Add categories |
||||
|
article.categories.set(article_data['categories']) |
||||
|
|
||||
|
# Add to collections |
||||
|
for collection in article_data['collections']: |
||||
|
from apps.article.models import ArticleInCollection |
||||
|
ArticleInCollection.objects.get_or_create( |
||||
|
collection=collection, |
||||
|
article=article, |
||||
|
defaults={'order': 1} |
||||
|
) |
||||
|
|
||||
|
# Create content sections |
||||
|
for content_data in article_data['content_sections']: |
||||
|
article_content = ArticleContent.objects.create( |
||||
|
article=article, |
||||
|
title=content_data['title'], |
||||
|
content=content_data['content'], |
||||
|
priority=content_data['priority'], |
||||
|
status=True |
||||
|
) |
||||
|
|
||||
|
# Create parts |
||||
|
for part_data in content_data['parts']: |
||||
|
content_part = ContentPart.objects.create( |
||||
|
article_content=article_content, |
||||
|
order=part_data['order'] |
||||
|
) |
||||
|
|
||||
|
# Create text sections |
||||
|
for section_data in part_data['text_sections']: |
||||
|
TextSection.objects.create( |
||||
|
content_part=content_part, |
||||
|
arabic_text=section_data['arabic_text'], |
||||
|
translation=section_data['translation'], |
||||
|
order=section_data['order'] |
||||
|
) |
||||
|
|
||||
|
articles.append(article) |
||||
|
|
||||
|
return articles |
||||
@ -0,0 +1,161 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-05-06 12:35 |
||||
|
|
||||
|
import django.db.models.deletion |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='ArticleCategory', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(max_length=255, verbose_name='title')), |
||||
|
('slug', models.SlugField(allow_unicode=True, unique=True, verbose_name='slug')), |
||||
|
('status', models.BooleanField(default=True, verbose_name='status')), |
||||
|
('order', models.PositiveIntegerField(default=0, 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')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Article Category', |
||||
|
'verbose_name_plural': 'Article Categories', |
||||
|
'ordering': ['order'], |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='ArticleCollection', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(help_text='This title will not be displayed anywhere', max_length=255)), |
||||
|
('slug', models.SlugField(max_length=255, unique=True)), |
||||
|
('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)), |
||||
|
('pin_top', models.BooleanField(default=True, verbose_name='pin top')), |
||||
|
('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='article/collection/')), |
||||
|
('order', models.IntegerField(default=0, verbose_name='order')), |
||||
|
('status', models.BooleanField(default=True, verbose_name='status')), |
||||
|
('display_position', models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position')), |
||||
|
('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': 'Article Collection', |
||||
|
'verbose_name_plural': 'Articles Collections', |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='Article', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(max_length=255, null=True)), |
||||
|
('slug', models.SlugField(allow_unicode=True, unique=True)), |
||||
|
('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='article_thumbnails/')), |
||||
|
('description', models.TextField(null=True)), |
||||
|
('content', models.TextField(null=True)), |
||||
|
('article_file', models.FileField(blank=True, help_text='PDF or other document files', null=True, upload_to='article/files/')), |
||||
|
('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), |
||||
|
('status', models.BooleanField(default=True, verbose_name='status')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), |
||||
|
('categories', models.ManyToManyField(blank=True, related_name='articles', to='article.articlecategory', verbose_name='categories')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Article', |
||||
|
'verbose_name_plural': 'Articles', |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='MiddleArticleCollection', |
||||
|
fields=[ |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Middle Section Article Collection', |
||||
|
'verbose_name_plural': 'Middle Section Article Collections', |
||||
|
'proxy': True, |
||||
|
'indexes': [], |
||||
|
'constraints': [], |
||||
|
}, |
||||
|
bases=('article.articlecollection',), |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='PinnedArticleCollection', |
||||
|
fields=[ |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Pinned Article Collection', |
||||
|
'verbose_name_plural': 'Pinned Article Collections', |
||||
|
'proxy': True, |
||||
|
'indexes': [], |
||||
|
'constraints': [], |
||||
|
}, |
||||
|
bases=('article.articlecollection',), |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='ArticleContent', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(max_length=255, verbose_name='title')), |
||||
|
('content', models.TextField(blank=True, verbose_name='content')), |
||||
|
('priority', models.PositiveIntegerField(default=0, verbose_name='priority')), |
||||
|
('status', models.BooleanField(default=True, verbose_name='status')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), |
||||
|
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contents', to='article.article', verbose_name='article')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Article Content', |
||||
|
'verbose_name_plural': 'Article Contents', |
||||
|
'ordering': ['priority'], |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='ArticleInCollection', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('order', models.PositiveIntegerField(default=0, 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')), |
||||
|
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_collections', to='article.article', verbose_name='article')), |
||||
|
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_articles', to='article.articlecollection', verbose_name='collection')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Article in Collection', |
||||
|
'verbose_name_plural': 'Articles in Collections', |
||||
|
'ordering': ['order'], |
||||
|
'unique_together': {('collection', 'article')}, |
||||
|
}, |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='articlecollection', |
||||
|
name='articles', |
||||
|
field=models.ManyToManyField(related_name='related_collections_article', through='article.ArticleInCollection', to='article.article', verbose_name='articles'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='article', |
||||
|
name='collections', |
||||
|
field=models.ManyToManyField(blank=True, related_name='related_articles', through='article.ArticleInCollection', to='article.articlecollection', verbose_name='collections'), |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='ContentPart', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('arabic_text', models.TextField(verbose_name='Arabic text')), |
||||
|
('translation', models.TextField(verbose_name='Translation')), |
||||
|
('order', models.PositiveIntegerField(default=0, 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')), |
||||
|
('article_content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='article.articlecontent', verbose_name='article content')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Content Part', |
||||
|
'verbose_name_plural': 'Content Parts', |
||||
|
'ordering': ['order'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,18 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-05-07 11:19 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('article', '0001_initial'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name='article', |
||||
|
name='download_count', |
||||
|
field=models.PositiveBigIntegerField(default=0, verbose_name='view count'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,47 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-12-02 16:17 |
||||
|
|
||||
|
import django.db.models.deletion |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('article', '0002_article_download_count'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterModelOptions( |
||||
|
name='middlearticlecollection', |
||||
|
options={'verbose_name': 'Regular Collection (Middle Section)', 'verbose_name_plural': 'Regular Collections (Middle Section)'}, |
||||
|
), |
||||
|
migrations.AlterModelOptions( |
||||
|
name='pinnedarticlecollection', |
||||
|
options={'verbose_name': 'Pinned Collection (Top Section)', 'verbose_name_plural': 'Pinned Collections (Top Section)'}, |
||||
|
), |
||||
|
migrations.RemoveField( |
||||
|
model_name='contentpart', |
||||
|
name='arabic_text', |
||||
|
), |
||||
|
migrations.RemoveField( |
||||
|
model_name='contentpart', |
||||
|
name='translation', |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='TextSection', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('arabic_text', models.TextField(verbose_name='Arabic text')), |
||||
|
('translation', models.TextField(verbose_name='Translation')), |
||||
|
('order', models.PositiveIntegerField(default=0, 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')), |
||||
|
('content_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='text_sections', to='article.contentpart', verbose_name='content part')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Text Section', |
||||
|
'verbose_name_plural': 'Text Sections', |
||||
|
'ordering': ['order'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,235 @@ |
|||||
|
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
from utils import generate_slug_for_model |
||||
|
|
||||
|
|
||||
|
class ArticleCategory(models.Model): |
||||
|
title = models.CharField(max_length=255, verbose_name=_('title')) |
||||
|
slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) |
||||
|
|
||||
|
status = models.BooleanField(default=True, verbose_name=_('status')) |
||||
|
order = models.PositiveIntegerField(default=0, 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')) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return self.title |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
if not self.slug: |
||||
|
self.slug = generate_slug_for_model(ArticleCategory, self.title) |
||||
|
super().save(*args, **kwargs) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Article Category') |
||||
|
verbose_name_plural = _('Article Categories') |
||||
|
ordering = ['order'] |
||||
|
|
||||
|
|
||||
|
class ArticleCollection(models.Model): |
||||
|
class DisplayPosition(models.TextChoices): |
||||
|
PINNED = 'pinned', _('Pinned') |
||||
|
MIDDLE = 'middle', _('Middle Section') |
||||
|
|
||||
|
title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere") |
||||
|
slug = models.SlugField(max_length=255, unique=True) |
||||
|
summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null')) |
||||
|
pin_top = models.BooleanField(_('pin top'), default=True) |
||||
|
thumbnail = models.ImageField(upload_to='article/collection/', null=True, blank=True, help_text=_('image allowed')) |
||||
|
order = models.IntegerField(default=0, verbose_name=_('order')) |
||||
|
status = models.BooleanField(default=True, verbose_name=_('status')) |
||||
|
display_position = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=DisplayPosition.choices, |
||||
|
default=DisplayPosition.PINNED, |
||||
|
verbose_name=_('Display Position') |
||||
|
) |
||||
|
|
||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) |
||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) |
||||
|
articles = models.ManyToManyField( |
||||
|
'Article', |
||||
|
through='ArticleInCollection', |
||||
|
related_name='related_collections_article', |
||||
|
verbose_name=_('articles'), |
||||
|
) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f'Collection #{self.id}/{self.title}' |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
if not self.slug: |
||||
|
self.slug = generate_slug_for_model(ArticleCollection, self.title) |
||||
|
super().save(*args, **kwargs) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Article Collection') |
||||
|
verbose_name_plural = _('Articles Collections') |
||||
|
|
||||
|
|
||||
|
class PinnedArticleCollection(ArticleCollection): |
||||
|
class Meta: |
||||
|
proxy = True |
||||
|
verbose_name = _('Pinned Collection (Top Section)') |
||||
|
verbose_name_plural = _('Pinned Collections (Top Section)') |
||||
|
|
||||
|
|
||||
|
class MiddleArticleCollection(ArticleCollection): |
||||
|
class Meta: |
||||
|
proxy = True |
||||
|
verbose_name = _('Regular Collection (Middle Section)') |
||||
|
verbose_name_plural = _('Regular Collections (Middle Section)') |
||||
|
|
||||
|
|
||||
|
class Article(models.Model): |
||||
|
|
||||
|
title = models.CharField(max_length=255, null=True) |
||||
|
slug = models.SlugField(allow_unicode=True, unique=True) |
||||
|
thumbnail = models.ImageField(upload_to='article_thumbnails/', null=True, blank=True, help_text=_('image allowed')) |
||||
|
description = models.TextField(null=True) |
||||
|
content = models.TextField(null=True) |
||||
|
article_file = models.FileField(upload_to='article/files/', null=True, blank=True, help_text=_('PDF or other document files')) |
||||
|
|
||||
|
categories = models.ManyToManyField(ArticleCategory, related_name='articles', verbose_name=_('categories'), blank=True) |
||||
|
collections = models.ManyToManyField( |
||||
|
ArticleCollection, |
||||
|
through='ArticleInCollection', |
||||
|
related_name='related_articles', |
||||
|
verbose_name=_('collections'), |
||||
|
blank=True |
||||
|
) |
||||
|
download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) |
||||
|
|
||||
|
view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) |
||||
|
|
||||
|
status = models.BooleanField(default=True, verbose_name=_('status')) |
||||
|
|
||||
|
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 increment_view_count(self): |
||||
|
self.view_count += 1 |
||||
|
self.save(update_fields=['view_count']) |
||||
|
return self.view_count |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
if not self.slug: |
||||
|
self.slug = generate_slug_for_model(Article, self.title) |
||||
|
super().save(*args, **kwargs) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Article') |
||||
|
verbose_name_plural = _('Articles') |
||||
|
|
||||
|
|
||||
|
|
||||
|
class ArticleInCollection(models.Model): |
||||
|
collection = models.ForeignKey( |
||||
|
ArticleCollection, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='collection_articles', |
||||
|
verbose_name=_('collection') |
||||
|
) |
||||
|
article = models.ForeignKey( |
||||
|
Article, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='article_collections', |
||||
|
verbose_name=_('article') |
||||
|
) |
||||
|
order = models.PositiveIntegerField(default=0, 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')) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.collection.title} - {self.article.title}" |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Article in Collection') |
||||
|
verbose_name_plural = _('Articles in Collections') |
||||
|
ordering = ['order'] |
||||
|
unique_together = ['collection', 'article'] |
||||
|
|
||||
|
|
||||
|
class ArticleContent(models.Model): |
||||
|
""" |
||||
|
Model for structured content sections within an article |
||||
|
""" |
||||
|
article = models.ForeignKey( |
||||
|
Article, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='contents', |
||||
|
verbose_name=_('article') |
||||
|
) |
||||
|
title = models.CharField(max_length=255, verbose_name=_('title')) |
||||
|
content = models.TextField(verbose_name=_('content'), blank=True) |
||||
|
priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) |
||||
|
|
||||
|
status = models.BooleanField(default=True, verbose_name=_('status')) |
||||
|
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: |
||||
|
verbose_name = _('Article Content') |
||||
|
verbose_name_plural = _('Article Contents') |
||||
|
ordering = ['priority'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.article.title} - {self.title}" |
||||
|
|
||||
|
|
||||
|
class ContentPart(models.Model): |
||||
|
""" |
||||
|
Model for content parts - each part can have multiple text sections |
||||
|
""" |
||||
|
article_content = models.ForeignKey( |
||||
|
ArticleContent, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='parts', |
||||
|
verbose_name=_('article content') |
||||
|
) |
||||
|
order = models.PositiveIntegerField(default=0, 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')) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Content Part') |
||||
|
verbose_name_plural = _('Content Parts') |
||||
|
ordering = ['order'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.article_content.title} - Part {self.order}" |
||||
|
|
||||
|
|
||||
|
class TextSection(models.Model): |
||||
|
""" |
||||
|
Model for bilingual text sections (Arabic text and translation) within a content part |
||||
|
""" |
||||
|
content_part = models.ForeignKey( |
||||
|
ContentPart, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='text_sections', |
||||
|
verbose_name=_('content part') |
||||
|
) |
||||
|
arabic_text = models.TextField(verbose_name=_('Arabic text')) |
||||
|
translation = models.TextField(verbose_name=_('Translation')) |
||||
|
order = models.PositiveIntegerField(default=0, 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')) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Text Section') |
||||
|
verbose_name_plural = _('Text Sections') |
||||
|
ordering = ['order'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.content_part} - Section {self.order}" |
||||
|
|
||||
|
|
||||
|
|
||||
@ -0,0 +1,147 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from utils import get_thumbs |
||||
|
from apps.article.models import * |
||||
|
from apps.bookmark.serializers import * |
||||
|
|
||||
|
|
||||
|
class ArticleCategoryListSerializer(serializers.ModelSerializer): |
||||
|
acticle_count = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = ArticleCategory |
||||
|
fields = ['id', 'title', 'slug', 'acticle_count'] |
||||
|
|
||||
|
def get_acticle_count(self, obj): |
||||
|
return obj.articles.filter(status=True).count() |
||||
|
|
||||
|
class PinnedArticleCollectionSerializer(serializers.ModelSerializer): |
||||
|
thumbnail = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = ArticleCollection |
||||
|
fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at'] |
||||
|
|
||||
|
def get_thumbnail(self, obj): |
||||
|
return get_thumbs(obj.thumbnail, self.context.get('request')) |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
class MiddleArticleCollectionSerializer(serializers.ModelSerializer): |
||||
|
articles = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = ArticleCollection |
||||
|
fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'pin_top', 'articles') |
||||
|
|
||||
|
def get_articles(self, obj): |
||||
|
articles = obj.articles.filter(status=True).order_by('-created_at') |
||||
|
return ArticleListSerializer(articles, many=True, context=self.context).data |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
class ArticleListSerializer(serializers.ModelSerializer): |
||||
|
thumbnail = serializers.SerializerMethodField() |
||||
|
categories = ArticleCategoryListSerializer(many=True, read_only=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = Article |
||||
|
fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'view_count', 'created_at', 'categories'] |
||||
|
|
||||
|
def get_thumbnail(self, obj): |
||||
|
return get_thumbs(obj.thumbnail, self.context.get('request')) |
||||
|
|
||||
|
|
||||
|
class TextSectionSerializer(serializers.ModelSerializer): |
||||
|
class Meta: |
||||
|
model = TextSection |
||||
|
fields = ['id', 'arabic_text', 'translation', 'order'] |
||||
|
|
||||
|
class ContentPartSerializer(serializers.ModelSerializer): |
||||
|
text_sections = TextSectionSerializer(many=True, read_only=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = ContentPart |
||||
|
fields = ['id', 'order', 'text_sections'] |
||||
|
|
||||
|
class ArticleContentSerializer(serializers.ModelSerializer): |
||||
|
parts = ContentPartSerializer(many=True, read_only=True) |
||||
|
class Meta: |
||||
|
model = ArticleContent |
||||
|
fields = ['id', 'title', 'content', 'priority', 'status', 'created_at', 'updated_at', 'parts'] |
||||
|
|
||||
|
class ArticleDetailSerializer(serializers.ModelSerializer): |
||||
|
categories = ArticleCategoryListSerializer(many=True, read_only=True) |
||||
|
thumbnail = serializers.SerializerMethodField() |
||||
|
bookmark = serializers.SerializerMethodField() |
||||
|
user_rate = serializers.SerializerMethodField() |
||||
|
average_rate = serializers.SerializerMethodField() |
||||
|
article_content = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = Article |
||||
|
fields = ['id', 'title', 'slug', 'thumbnail', 'description', |
||||
|
'article_file', 'view_count', 'download_count', |
||||
|
'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', 'article_content'] |
||||
|
|
||||
|
def get_thumbnail(self, obj): |
||||
|
return get_thumbs(obj.thumbnail, self.context.get('request')) |
||||
|
|
||||
|
def get_bookmark(self, obj): |
||||
|
""" |
||||
|
Get bookmark information for this article. |
||||
|
""" |
||||
|
# Get the current user from the request context |
||||
|
request = self.context.get('request') |
||||
|
user = request.user if request else None |
||||
|
book_mark = BookmarkStatusSerializer.get_bookmark_info( |
||||
|
obj=obj, |
||||
|
user=user, |
||||
|
service='article', |
||||
|
) |
||||
|
return book_mark.get('is_bookmarked', False) |
||||
|
|
||||
|
def get_user_rate(self, obj): |
||||
|
""" |
||||
|
Get rate information for this article from the current user. |
||||
|
""" |
||||
|
from apps.bookmark.models.rate import Rate |
||||
|
|
||||
|
# Get the current user from the request context |
||||
|
request = self.context.get('request') |
||||
|
user = request.user if request and request.user.is_authenticated else None |
||||
|
|
||||
|
if not user: |
||||
|
return { |
||||
|
'is_rated': False, |
||||
|
'rate': None |
||||
|
} |
||||
|
|
||||
|
# Get rate information using the Rate model's method |
||||
|
rate_info = Rate.get_user_rate( |
||||
|
user=user, |
||||
|
service='article', |
||||
|
content_id=obj.id |
||||
|
) |
||||
|
|
||||
|
return rate_info |
||||
|
|
||||
|
def get_average_rate(self, obj): |
||||
|
""" |
||||
|
Get the average rate for this article. |
||||
|
""" |
||||
|
from apps.bookmark.models.rate import Rate |
||||
|
|
||||
|
# Get average rate information using the Rate model |
||||
|
return Rate.get_average_rate( |
||||
|
service='article', |
||||
|
content_id=obj.id |
||||
|
) |
||||
|
|
||||
|
def get_article_content(self, obj): |
||||
|
""" |
||||
|
Get the content of the article. |
||||
|
""" |
||||
|
content = obj.contents.all() |
||||
|
return ArticleContentSerializer(content, many=True, context=self.context).data |
||||
@ -0,0 +1,4 @@ |
|||||
|
{% load i18n %} |
||||
|
{% load unfold %} |
||||
|
{% load course_tags %} |
||||
|
|
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.test import TestCase |
||||
|
|
||||
|
# Create your tests here. |
||||
@ -0,0 +1,17 @@ |
|||||
|
from django.urls import path, re_path |
||||
|
from .views import * |
||||
|
|
||||
|
app_name = 'article' |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
path('categories/', ArticleCategoryListAPIView.as_view(), name='category-list'), |
||||
|
path('pinned-collections/', PinnedArticleCollectionListView.as_view(), name='pinned-collection-list'), |
||||
|
path('collections/', MiddleArticleCollectionListView.as_view(), name='collection-list'), |
||||
|
|
||||
|
path('list/', ArticleListAPIView.as_view(), name='podcast-list'), |
||||
|
re_path(r'detail/(?P<slug>[\w-]+)/$', ArticleDetailAPIView.as_view(), name='podcast-detail'), |
||||
|
|
||||
|
# # User playlist endpoints |
||||
|
# path('user-playlist/', UserPlaylistCreateAPIView.as_view(), name='user-playlist-create'), |
||||
|
# path('user-playlist/list/', UserPlaylistListAPIView.as_view(), name='user-playlist-list'), |
||||
|
] |
||||
@ -0,0 +1,237 @@ |
|||||
|
from rest_framework import generics, status |
||||
|
from rest_framework.response import Response |
||||
|
from drf_yasg import openapi |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from apps.library.pagination import NoPagination |
||||
|
from rest_framework.permissions import IsAuthenticated |
||||
|
|
||||
|
|
||||
|
from apps.article.models import * |
||||
|
from apps.article.serializers import * |
||||
|
|
||||
|
|
||||
|
class ArticleCategoryListAPIView(generics.ListAPIView): |
||||
|
serializer_class = ArticleCategoryListSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get a list of all active article categories", |
||||
|
tags=["Dobodbi - Article"], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of article categories", |
||||
|
schema=ArticleCategoryListSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return ArticleCategory.objects.filter(status=True).order_by('order') |
||||
|
|
||||
|
|
||||
|
class PinnedArticleCollectionListView(generics.ListAPIView): |
||||
|
serializer_class = PinnedArticleCollectionSerializer |
||||
|
permission_classes = (IsAuthenticated,) |
||||
|
pagination_class = NoPagination |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get a list of pinned article collections", |
||||
|
tags=["Dobodbi - Article"], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of pinned article collections", |
||||
|
schema=PinnedArticleCollectionSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return PinnedArticleCollection.objects.filter( |
||||
|
status=True, |
||||
|
display_position=ArticleCollection.DisplayPosition.PINNED |
||||
|
).order_by('-order', '-id') |
||||
|
|
||||
|
def list(self, request, *args, **kwargs): |
||||
|
response = super().list(request, *args, **kwargs) |
||||
|
categories_count = ArticleCategory.objects.filter(status=True).count() |
||||
|
from apps.bookmark.models import Bookmark |
||||
|
bookmarks_count = Bookmark.objects.filter( |
||||
|
service=Bookmark.ServiceChoices.ARTICLE, |
||||
|
).count() |
||||
|
|
||||
|
info = { |
||||
|
"categories_count": categories_count, |
||||
|
"bookmarks_count": bookmarks_count, |
||||
|
} |
||||
|
|
||||
|
data = { |
||||
|
"count": response.data.get("count"), |
||||
|
"next": response.data.get("next"), |
||||
|
"previous": response.data.get("previous"), |
||||
|
"info": info, |
||||
|
"results": response.data.get("results") |
||||
|
} |
||||
|
return Response(data, status=status.HTTP_200_OK) |
||||
|
|
||||
|
class MiddleArticleCollectionListView(generics.ListAPIView): |
||||
|
serializer_class = MiddleArticleCollectionSerializer |
||||
|
permission_classes = (IsAuthenticated,) |
||||
|
pagination_class = NoPagination |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get a list of middle article collections", |
||||
|
tags=["Dobodbi - Article"], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of middle article collections", |
||||
|
schema=MiddleArticleCollectionSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return ArticleCollection.objects.filter( |
||||
|
status=True, |
||||
|
display_position=ArticleCollection.DisplayPosition.MIDDLE |
||||
|
).order_by('order') |
||||
|
|
||||
|
|
||||
|
|
||||
|
class ArticleListAPIView(generics.ListAPIView): |
||||
|
serializer_class = ArticleListSerializer |
||||
|
permission_classes = (IsAuthenticated,) |
||||
|
|
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get a list of articles with optional filtering and sorting", |
||||
|
tags=["Dobodbi - Article"], |
||||
|
manual_parameters=[ |
||||
|
openapi.Parameter( |
||||
|
name='category', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description='Filter articles by category slug(s). Can be a single slug or comma-separated list of slugs', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
), |
||||
|
openapi.Parameter( |
||||
|
name='collection', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description='Filter articles by collection slug', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
), |
||||
|
openapi.Parameter( |
||||
|
name='is_bookmark', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description='Filter articles that are bookmarked by the user (true/false)', |
||||
|
type=openapi.TYPE_BOOLEAN, |
||||
|
required=False |
||||
|
), |
||||
|
openapi.Parameter( |
||||
|
name='search', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description='Search articles by title', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
), |
||||
|
openapi.Parameter( |
||||
|
name='sort', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description='Sort articles by field. Options: created_at, -created_at, view_count, -view_count, title, -title', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
) |
||||
|
], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of articles", |
||||
|
schema=ArticleListSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
queryset = Article.objects.filter(status=True) |
||||
|
|
||||
|
# Search by title if search parameter is provided |
||||
|
search_query = self.request.query_params.get('search', None) |
||||
|
if search_query: |
||||
|
queryset = queryset.filter(title__icontains=search_query) |
||||
|
|
||||
|
# Filter by category if provided |
||||
|
category = self.request.query_params.get('category', None) |
||||
|
if category: |
||||
|
# Support both single slug and comma-separated list of slugs |
||||
|
category_slugs = [slug.strip() for slug in category.split(',')] |
||||
|
queryset = queryset.filter(categories__slug__in=category_slugs).distinct() |
||||
|
|
||||
|
# Filter by collection if provided |
||||
|
collection_slug = self.request.query_params.get('collection', None) |
||||
|
if collection_slug: |
||||
|
# Get all articles that are in the collection with the given slug |
||||
|
queryset = queryset.filter( |
||||
|
collections__slug=collection_slug |
||||
|
) |
||||
|
|
||||
|
|
||||
|
# Filter by bookmarks if provided |
||||
|
is_bookmark = self.request.query_params.get('is_bookmark', '').lower() |
||||
|
if is_bookmark == 'true': |
||||
|
# Import Bookmark model here to avoid circular imports |
||||
|
from apps.bookmark.models import Bookmark |
||||
|
|
||||
|
# Get all bookmarked article IDs for the current user |
||||
|
bookmarked_ids = Bookmark.objects.filter( |
||||
|
user=self.request.user, |
||||
|
service=Bookmark.ServiceChoices.ARTICLE, |
||||
|
status=True |
||||
|
).values_list('content_id', flat=True) |
||||
|
|
||||
|
# Filter articles by these IDs |
||||
|
queryset = queryset.filter(id__in=bookmarked_ids) |
||||
|
|
||||
|
# Sort by parameter |
||||
|
sort = self.request.query_params.get('sort', '-created_at') |
||||
|
# Allowed sort fields |
||||
|
allowed_sorts = ['created_at', '-created_at', 'view_count', '-view_count', 'title', '-title'] |
||||
|
if sort in allowed_sorts: |
||||
|
queryset = queryset.order_by(sort) |
||||
|
else: |
||||
|
queryset = queryset.order_by('-created_at') |
||||
|
|
||||
|
return queryset |
||||
|
|
||||
|
|
||||
|
class ArticleDetailAPIView(generics.RetrieveAPIView): |
||||
|
serializer_class = ArticleDetailSerializer |
||||
|
permission_classes = (IsAuthenticated,) |
||||
|
lookup_field = 'slug' |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get article details by slug", |
||||
|
tags=["Dobodbi - Article"], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="Article details", |
||||
|
schema=ArticleDetailSerializer() |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return Article.objects.filter(status=True) |
||||
|
|
||||
|
def retrieve(self, request, *args, **kwargs): |
||||
|
instance = self.get_object() |
||||
|
instance.increment_view_count() |
||||
|
serializer = self.get_serializer(instance) |
||||
|
return Response(serializer.data) |
||||
@ -0,0 +1,119 @@ |
|||||
|
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), |
||||
|
} |
||||
|
|
||||
|
|
||||
|
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) |
||||
@ -0,0 +1,7 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class BlogConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.blog' |
||||
|
verbose_name = 'Blog' |
||||
@ -0,0 +1,367 @@ |
|||||
|
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.")) |
||||
@ -0,0 +1,53 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-10 20:47 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
] |
||||
|
|
||||
|
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='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'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,31 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-11 12:17 |
||||
|
|
||||
|
import dj_language.field |
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('dj_language', '0002_auto_20220120_1344'), |
||||
|
('blog', '0001_initial'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
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', |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,39 @@ |
|||||
|
# Generated manually to convert varchar fields to jsonb |
||||
|
|
||||
|
from django.db import migrations |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('blog', '0002_blogseo'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.RunSQL( |
||||
|
sql=""" |
||||
|
-- Step 1: Drop constraints and indexes on slug fields |
||||
|
ALTER TABLE blog_blog DROP CONSTRAINT IF EXISTS blog_blog_slug_key; |
||||
|
DROP INDEX IF EXISTS blog_blog_slug_4812aa2c_like; |
||||
|
DROP INDEX IF EXISTS blog_blogcontent_slug_4842a829; |
||||
|
DROP INDEX IF EXISTS blog_blogcontent_slug_4842a829_like; |
||||
|
|
||||
|
-- Step 2: Convert Blog table fields to jsonb |
||||
|
ALTER TABLE blog_blog ALTER COLUMN title TYPE jsonb USING '[]'::jsonb; |
||||
|
ALTER TABLE blog_blog ALTER COLUMN slogan TYPE jsonb USING '[]'::jsonb; |
||||
|
ALTER TABLE blog_blog ALTER COLUMN slug TYPE jsonb USING '[]'::jsonb; |
||||
|
|
||||
|
-- Step 3: Convert BlogContent table fields to jsonb |
||||
|
ALTER TABLE blog_blogcontent ALTER COLUMN title TYPE jsonb USING '[]'::jsonb; |
||||
|
ALTER TABLE blog_blogcontent ALTER COLUMN slug TYPE jsonb USING '[]'::jsonb; |
||||
|
""", |
||||
|
reverse_sql=""" |
||||
|
ALTER TABLE blog_blog ALTER COLUMN title TYPE varchar(255); |
||||
|
ALTER TABLE blog_blog ALTER COLUMN slogan TYPE varchar(500); |
||||
|
ALTER TABLE blog_blog ALTER COLUMN slug TYPE varchar(255); |
||||
|
ALTER TABLE blog_blogcontent ALTER COLUMN title TYPE varchar(255); |
||||
|
ALTER TABLE blog_blogcontent ALTER COLUMN slug TYPE varchar(255); |
||||
|
""" |
||||
|
), |
||||
|
] |
||||
|
|
||||
@ -0,0 +1,200 @@ |
|||||
|
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=True, blank=True, 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=True, blank=True, 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( |
||||
|
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( |
||||
|
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) |
||||
|
|
||||
|
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 ''}" |
||||
@ -0,0 +1,142 @@ |
|||||
|
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()) |
||||
|
|
||||
|
|
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.test import TestCase |
||||
|
|
||||
|
# Create your tests here. |
||||
@ -0,0 +1,24 @@ |
|||||
|
from django.urls import path, re_path |
||||
|
from .views import BlogListAPIView, RelatedBlogsAPIView, BlogDetailBySlugAPIView |
||||
|
|
||||
|
app_name = 'blog' |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
# Blog list with search and sort_by filters |
||||
|
path('list/', BlogListAPIView.as_view(), name='blog-list'), |
||||
|
|
||||
|
# Related blogs for a specific blog ID |
||||
|
path('related/<int:blog_id>/', RelatedBlogsAPIView.as_view(), name='related-blogs'), |
||||
|
|
||||
|
# Blog detail by slug (using regex to support different languages) |
||||
|
re_path(r'^detail/(?P<slug>[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$', |
||||
|
BlogDetailBySlugAPIView.as_view(), |
||||
|
name='blog-detail'), |
||||
|
] |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
@ -0,0 +1,181 @@ |
|||||
|
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 |
||||
|
|
||||
|
|
||||
|
class BlogListAPIView(ListAPIView): |
||||
|
""" |
||||
|
API view to list blogs with search and sort_by filters |
||||
|
""" |
||||
|
serializer_class = BlogListSerializer |
||||
|
permission_classes = [AllowAny] |
||||
|
|
||||
|
@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 |
||||
|
) |
||||
@ -0,0 +1,143 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin |
||||
|
from unfold.decorators import display, action |
||||
|
from django.utils.html import format_html |
||||
|
from django.urls import reverse |
||||
|
|
||||
|
from apps.bookmark.models import Bookmark |
||||
|
from apps.bookmark.models import Rate |
||||
|
from utils.admin import project_admin_site , dovoodi_admin_site |
||||
|
|
||||
|
class BookmarkAdmin(ModelAdmin): |
||||
|
list_display = ('user', 'display_service', 'content_id', 'status', 'created_at') |
||||
|
list_filter = ('service', 'status', 'created_at') |
||||
|
search_fields = ('user__username', 'user__email', 'content_id') |
||||
|
readonly_fields = ('created_at', 'updated_at') |
||||
|
list_per_page = 20 |
||||
|
date_hierarchy = 'created_at' |
||||
|
list_filter_submit = True |
||||
|
warn_unsaved_form = True |
||||
|
change_form_show_cancel_button = True |
||||
|
|
||||
|
@display(description=_('Service')) |
||||
|
def display_service(self, obj): |
||||
|
service_colors = { |
||||
|
'library': 'primary', |
||||
|
'podcast': 'success', |
||||
|
'hadith': 'warning', |
||||
|
'video': 'danger' |
||||
|
} |
||||
|
color = service_colors.get(obj.service, 'secondary') |
||||
|
return format_html( |
||||
|
'<span class="badge badge-{}">{}</span>', |
||||
|
color, |
||||
|
obj.get_service_display() |
||||
|
) |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('user', 'service', 'content_id') |
||||
|
}), |
||||
|
(_('Status'), { |
||||
|
'fields': ('status',) |
||||
|
}), |
||||
|
(_('Timestamps'), { |
||||
|
'fields': ('created_at', 'updated_at') |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
@action(description=_("View Content")) |
||||
|
def view_content(self, request, obj): |
||||
|
""" |
||||
|
Action to view the related content based on service type |
||||
|
""" |
||||
|
service = obj.service |
||||
|
content_id = obj.content_id |
||||
|
|
||||
|
if service == 'library': |
||||
|
url = reverse('admin:library_book_change', args=[content_id]) |
||||
|
elif service == 'podcast': |
||||
|
url = reverse('admin:podcast_podcast_change', args=[content_id]) |
||||
|
elif service == 'hadith': |
||||
|
url = reverse('admin:hadith_hadith_change', args=[content_id]) |
||||
|
elif service == 'video': |
||||
|
url = reverse('admin:video_video_change', args=[content_id]) |
||||
|
else: |
||||
|
return None |
||||
|
|
||||
|
return url |
||||
|
|
||||
|
class RateAdmin(ModelAdmin): |
||||
|
list_display = ('user', 'display_service', 'content_id', 'display_rate', 'status', 'created_at') |
||||
|
list_filter = ('service', 'rate', 'status', 'created_at') |
||||
|
search_fields = ('user__username', 'user__email', 'content_id') |
||||
|
readonly_fields = ('created_at', 'updated_at') |
||||
|
list_per_page = 20 |
||||
|
date_hierarchy = 'created_at' |
||||
|
list_filter_submit = True |
||||
|
warn_unsaved_form = True |
||||
|
change_form_show_cancel_button = True |
||||
|
|
||||
|
@display(description=_('Service')) |
||||
|
def display_service(self, obj): |
||||
|
service_colors = { |
||||
|
'library': 'primary', |
||||
|
'podcast': 'success', |
||||
|
'hadith': 'warning', |
||||
|
'video': 'danger' |
||||
|
} |
||||
|
color = service_colors.get(obj.service, 'secondary') |
||||
|
return format_html( |
||||
|
'<span class="badge badge-{}">{}</span>', |
||||
|
color, |
||||
|
obj.get_service_display() |
||||
|
) |
||||
|
|
||||
|
@display(description=_('Rate')) |
||||
|
def display_rate(self, obj): |
||||
|
# Display stars based on rate value |
||||
|
stars = '★' * obj.rate + '☆' * (5 - obj.rate) |
||||
|
color = 'warning' # Yellow color for stars |
||||
|
return format_html( |
||||
|
'<span style="color: var(--bs-{});">{}</span>', |
||||
|
color, |
||||
|
stars |
||||
|
) |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('user', 'service', 'content_id', 'rate') |
||||
|
}), |
||||
|
(_('Status'), { |
||||
|
'fields': ('status',) |
||||
|
}), |
||||
|
(_('Timestamps'), { |
||||
|
'fields': ('created_at', 'updated_at') |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
@action(description=_("View Content")) |
||||
|
def view_content(self, request, obj): |
||||
|
""" |
||||
|
Action to view the related content based on service type |
||||
|
""" |
||||
|
service = obj.service |
||||
|
content_id = obj.content_id |
||||
|
|
||||
|
if service == 'library': |
||||
|
url = reverse('admin:library_book_change', args=[content_id]) |
||||
|
elif service == 'podcast': |
||||
|
url = reverse('admin:podcast_podcast_change', args=[content_id]) |
||||
|
elif service == 'hadith': |
||||
|
url = reverse('admin:hadith_hadith_change', args=[content_id]) |
||||
|
elif service == 'video': |
||||
|
url = reverse('admin:video_video_change', args=[content_id]) |
||||
|
else: |
||||
|
return None |
||||
|
|
||||
|
return url |
||||
|
|
||||
|
# Register with dovoodi admin site |
||||
|
dovoodi_admin_site.register(Bookmark, BookmarkAdmin) |
||||
|
dovoodi_admin_site.register(Rate, RateAdmin) |
||||
@ -0,0 +1,6 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class BookmarkConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.bookmark' |
||||
@ -0,0 +1,34 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-04-23 10:30 |
||||
|
|
||||
|
import django.db.models.deletion |
||||
|
from django.conf import settings |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='Bookmark', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('service', models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('hadith', 'Hadith'), ('video', 'Video')], max_length=20, verbose_name='Service')), |
||||
|
('content_id', models.PositiveIntegerField(verbose_name='Content ID')), |
||||
|
('status', models.BooleanField(default=True, verbose_name='Status')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Bookmark', |
||||
|
'verbose_name_plural': 'Bookmarks', |
||||
|
'unique_together': {('user', 'service', 'content_id')}, |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,35 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-05-04 15:36 |
||||
|
|
||||
|
import django.core.validators |
||||
|
import django.db.models.deletion |
||||
|
from django.conf import settings |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('bookmark', '0001_initial'), |
||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='Rate', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('service', models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('hadith', 'Hadith'), ('video', 'Video')], max_length=20, verbose_name='Service')), |
||||
|
('content_id', models.PositiveIntegerField(verbose_name='Content ID')), |
||||
|
('rate', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)], verbose_name='Rate')), |
||||
|
('status', models.BooleanField(default=True, verbose_name='Status')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rates', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Rate', |
||||
|
'verbose_name_plural': 'Rates', |
||||
|
'unique_together': {('user', 'service', 'content_id')}, |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,18 @@ |
|||||
|
# Generated by Django 5.1.8 on 2025-11-21 03:22 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('bookmark', '0002_rate'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='bookmark', |
||||
|
name='service', |
||||
|
field=models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('hadith', 'Hadith'), ('video', 'Video'), ('article', 'Article')], max_length=20, verbose_name='Service'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,23 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-11-30 17:58 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('bookmark', '0003_add_article_service_choice'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='bookmark', |
||||
|
name='service', |
||||
|
field=models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('hadith', 'Hadith'), ('video', 'Video'), ('video_playlist', 'Video Playlist'), ('article', 'Article')], max_length=20, verbose_name='Service'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='rate', |
||||
|
name='service', |
||||
|
field=models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('hadith', 'Hadith'), ('video', 'Video'), ('video_playlist', 'Video Playlist')], max_length=20, verbose_name='Service'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,23 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-12-02 12:45 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('bookmark', '0004_auto_20251130_1758'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='bookmark', |
||||
|
name='service', |
||||
|
field=models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('podcast_playlist', 'Podcast Playlist'), ('hadith', 'Hadith'), ('video', 'Video'), ('video_playlist', 'Video Playlist'), ('article', 'Article')], max_length=20, verbose_name='Service'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='rate', |
||||
|
name='service', |
||||
|
field=models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('podcast_playlist', 'Podcast Playlist'), ('hadith', 'Hadith'), ('video', 'Video'), ('video_playlist', 'Video Playlist')], max_length=20, verbose_name='Service'), |
||||
|
), |
||||
|
] |
||||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue