Browse Source

master merged w develop

master
Mohsen Taba 5 months ago
parent
commit
c9a043ac8f
  1. 88
      .dockerignore
  2. 8
      .env.prod
  3. 83
      .zencoder/rules/repo.md
  4. 132
      BUGFIX_REPORT.md
  5. 80
      CLAUDE.md
  6. 5
      Dockerfile.prod
  7. 134
      OPTIMIZATION_PLAN.md
  8. 180
      PODCAST_REFACTORING_SUMMARY.md
  9. 350
      PODCAST_SETUP_GUIDE.md
  10. 328
      README_WEBHOOK.md
  11. 164
      VIDEO_REFACTORING_SUMMARY.md
  12. 607
      adjustemnts.md
  13. 70
      apps/account/admin/__init__.py
  14. 59
      apps/account/admin/location.py
  15. 12
      apps/account/admin/notification.py
  16. 76
      apps/account/admin/professor.py
  17. 38
      apps/account/admin/student.py
  18. 381
      apps/account/admin/user.py
  19. 28
      apps/account/management/commands/assign_professor_slugs.py
  20. 158
      apps/account/management/commands/migrate_user_roles.py
  21. 19
      apps/account/manager.py
  22. 114
      apps/account/middleware/admin_access.py
  23. 59
      apps/account/migrations/0001_initial.py
  24. 20
      apps/account/migrations/0002_alter_user_phone_number.py
  25. 30
      apps/account/migrations/0003_locationhistory.py
  26. 18
      apps/account/migrations/0004_alter_user_avatar.py
  27. 17
      apps/account/migrations/0005_alter_user_unique_together.py
  28. 45
      apps/account/migrations/0006_auto_20251006_1101.py
  29. 17
      apps/account/migrations/0007_user_user_agent.py
  30. 22
      apps/account/migrations/0008_loginhistory_device_os_loginhistory_user_agent.py
  31. 17
      apps/account/migrations/0009_user_client_ip.py
  32. 21
      apps/account/migrations/0010_alter_user_device_os.py
  33. 197
      apps/account/models/user.py
  34. 5
      apps/account/serializers/__init__.py
  35. 11
      apps/account/serializers/auth.py
  36. 37
      apps/account/serializers/location_history.py
  37. 138
      apps/account/serializers/user.py
  38. 29
      apps/account/serializers/user_web.py
  39. 101
      apps/account/tasks.py
  40. 40
      apps/account/templates/account/group_help_text.html
  41. 800
      apps/account/templates/account/json_editor_field.html
  42. 33
      apps/account/templates/account/user_list_section.html
  43. 240
      apps/account/tests/test_multiple_roles.py
  44. 10
      apps/account/urls.py
  45. 6
      apps/account/views/__init__.py
  46. 126
      apps/account/views/auth.py
  47. 358
      apps/account/views/location_history.py
  48. 60
      apps/account/views/notification.py
  49. 225
      apps/account/views/user.py
  50. 108
      apps/api/admin.py
  51. 42
      apps/api/decorators.py
  52. 31
      apps/api/migrations/0001_initial.py
  53. 42
      apps/api/migrations/0002_auto_20250911_1217.py
  54. 0
      apps/api/migrations/__init__.py
  55. 137
      apps/api/models.py
  56. 60
      apps/api/permissions.py
  57. 54
      apps/api/serializers.py
  58. 7
      apps/api/urls.py
  59. 44
      apps/api/views.py
  60. 16
      apps/api/views/__init__.py
  61. 100
      apps/api/views/api_views.py
  62. 1061
      apps/api/views/documentation.py
  63. 83
      apps/api/views/swagger_views.py
  64. 0
      apps/article/__init__.py
  65. 314
      apps/article/admin.py
  66. 6
      apps/article/apps.py
  67. 0
      apps/article/management/__init__.py
  68. 0
      apps/article/management/commands/__init__.py
  69. 445
      apps/article/management/commands/seed_article_data.py
  70. 161
      apps/article/migrations/0001_initial.py
  71. 18
      apps/article/migrations/0002_article_download_count.py
  72. 47
      apps/article/migrations/0003_alter_middlearticlecollection_options_and_more.py
  73. 0
      apps/article/migrations/__init__.py
  74. 235
      apps/article/models.py
  75. 147
      apps/article/serializers.py
  76. 4
      apps/article/templates/article/change_form_before_template.html
  77. 3
      apps/article/tests.py
  78. 17
      apps/article/urls.py
  79. 237
      apps/article/views.py
  80. 0
      apps/blog/__init__.py
  81. 119
      apps/blog/admin.py
  82. 7
      apps/blog/apps.py
  83. 367
      apps/blog/management/commands/seed_blog_data.py
  84. 53
      apps/blog/migrations/0001_initial.py
  85. 31
      apps/blog/migrations/0002_blogseo.py
  86. 39
      apps/blog/migrations/0003_convert_varchar_to_jsonb.py
  87. 0
      apps/blog/migrations/__init__.py
  88. 200
      apps/blog/models.py
  89. 142
      apps/blog/serializers.py
  90. 3
      apps/blog/tests.py
  91. 24
      apps/blog/urls.py
  92. 181
      apps/blog/views.py
  93. 0
      apps/bookmark/__init__.py
  94. 143
      apps/bookmark/admin.py
  95. 6
      apps/bookmark/apps.py
  96. 34
      apps/bookmark/migrations/0001_initial.py
  97. 35
      apps/bookmark/migrations/0002_rate.py
  98. 18
      apps/bookmark/migrations/0003_add_article_service_choice.py
  99. 23
      apps/bookmark/migrations/0004_auto_20251130_1758.py
  100. 23
      apps/bookmark/migrations/0005_auto_20251202_1245.py

88
.dockerignore

@ -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

8
.env.prod

@ -1,4 +1,4 @@
DJANGO_ALLOWED_HOSTS=127.0.0.1,imamjavad.nwhco.ir,www.imamjavad.nwhco.ir,*.nwhco.ir,188.40.92.124,88.99.212.243
DJANGO_ALLOWED_HOSTS=127.0.0.1,imamjavad.nwhco.ir,www.imamjavad.nwhco.ir,*.nwhco.ir,188.40.92.124,88.99.212.243,dovodi.newhorizonco.uk,*.newhorizonco.uk,0.0.0.0,imamjavad.newhorizonco.uk
DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SETTINGS_MODULE=config.settings.production
@ -17,10 +17,10 @@ CELERY_BACKEND=redis://imam-javad_redis:6379/0
FLOWER_UNAUTHENTICATED_API=true FLOWER_UNAUTHENTICATED_API=true
TIMEZONE="Asia/Tehran" TIMEZONE="Asia/Tehran"
CELERY_TIMEZONE="Asia/Tehran" CELERY_TIMEZONE="Asia/Tehran"
UNFOLD_STUDIO="1"
PLAUSIBLE_DOMAIN='http://127.0.0.1:8000/'
#[captcha] #[captcha]
captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2" captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2"
captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS" captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS"
ONLINE_CLASS_FRONTEND_DOMAIN="imamjavad.newhorizonco.uk"
FCM_API_KEY="" FCM_API_KEY=""

83
.zencoder/rules/repo.md

@ -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

132
BUGFIX_REPORT.md

@ -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

80
CLAUDE.md

@ -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)

5
Dockerfile.prod

@ -8,7 +8,7 @@ WORKDIR /usr/src/app
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# install psycopg2 dependencies
# install psycopg2 dependencies and ffmpeg
RUN apk update && apk add --no-cache \ RUN apk update && apk add --no-cache \
git \ git \
wget \ wget \
@ -29,7 +29,8 @@ RUN apk update && apk add --no-cache \
freetype \ freetype \
ttf-freefont \ ttf-freefont \
mesa-gl \ mesa-gl \
alsa-lib
alsa-lib \
ffmpeg
# Set environment variables for Chrome # Set environment variables for Chrome

134
OPTIMIZATION_PLAN.md

@ -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

180
PODCAST_REFACTORING_SUMMARY.md

@ -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 🔄 |

350
PODCAST_SETUP_GUIDE.md

@ -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 ها و توضیحات کپی شده
- ✅ سیستم آماده برای استفاده
سیستم پادکست شما کاملاً آماده است! 🎉

328
README_WEBHOOK.md

@ -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)

164
VIDEO_REFACTORING_SUMMARY.md

@ -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

607
adjustemnts.md

@ -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
]
}
}
]

70
apps/account/admin/__init__.py

@ -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

59
apps/account/admin/location.py

@ -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)

12
apps/account/admin/notification.py

@ -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',]

76
apps/account/admin/professor.py

@ -1,5 +1,10 @@
<<<<<<< HEAD
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.forms import UserChangeForm, UsernameField from django.contrib.auth.forms import UserChangeForm, UsernameField
=======
# This file is no longer used. All admin classes are now in user.pyfrom django.contrib import admin
from django.contrib.auth.forms import UserChangeForm, UsernameField, UserCreationForm
>>>>>>> develop
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import TokenProxy from rest_framework.authtoken.models import TokenProxy
@ -8,19 +13,46 @@ from ajaxdatatable.admin import AjaxDatatable
from django.contrib import admin from django.contrib import admin
from apps.account.models import User from apps.account.models import User
from django import forms from django import forms
<<<<<<< HEAD
from django.contrib import admin from django.contrib import admin
from django.urls import path, reverse from django.urls import path, reverse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib import messages from django.contrib import messages
=======
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth.models import Group
from phonenumber_field.formfields import PhoneNumberField
>>>>>>> develop
from apps.account.models import ProfessorUser from apps.account.models import ProfessorUser
<<<<<<< HEAD
@admin.register(ProfessorUser) @admin.register(ProfessorUser)
class ProfessorUserAdmin(UserAdmin, AjaxDatatable): class ProfessorUserAdmin(UserAdmin, AjaxDatatable):
list_display = ( list_display = (
'device_id', 'email', 'fullname', 'user_type','last_login', 'date_joined', 'device_id', 'email', 'fullname', 'user_type','last_login', 'date_joined',
=======
class ProfessorUserCreationForm(UserCreationForm):
phone_number = PhoneNumberField(
help_text="Enter the phone number in international format. Example: +989012023212",
required=False
)
class Meta:
model = ProfessorUser
fields = ('fullname', 'email', 'phone_number')
@admin.register(ProfessorUser)
class ProfessorUserAdmin(UserAdmin, AjaxDatatable):
add_form = ProfessorUserCreationForm
list_display = (
'email', 'fullname', 'last_login', 'date_joined',
>>>>>>> develop
) )
ordering = 'last_login', ordering = 'last_login',
readonly_fields = ('date_joined',) readonly_fields = ('date_joined',)
@ -52,10 +84,54 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable):
) )
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
<<<<<<< HEAD
if not change: if not change:
obj.set_password(form.cleaned_data['password1']) obj.set_password(form.cleaned_data['password1'])
obj.user_type = User.UserType.PROFESSOR obj.user_type = User.UserType.PROFESSOR
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
=======
if not change: # Creating a new professor
# Check if a user with this email already exists
email = form.cleaned_data.get('email')
existing_user = User.objects.filter(email=email).first()
if existing_user:
# If user exists and is already a professor, show error
if existing_user.user_type == User.UserType.PROFESSOR:
messages.error(request, f"A professor with the email {email} already exists.")
return
# اضافه کردن نقش professor بدون حذف نقش‌های قبلی
existing_user.add_role('professor')
# Update user fields from form data
existing_user.fullname = form.cleaned_data.get('fullname')
existing_user.phone_number = form.cleaned_data.get('phone_number')
existing_user.avatar = form.cleaned_data.get('avatar')
existing_user.info = form.cleaned_data.get('info')
existing_user.skill = form.cleaned_data.get('skill')
# Set password if provided
if 'password1' in form.cleaned_data and form.cleaned_data['password1']:
existing_user.set_password(form.cleaned_data['password1'])
# Save the user
existing_user.save()
# Show success message
messages.success(request, f"The user with email {email} has been converted to a professor.")
# Set obj to None to prevent further processing
obj = None
return
else:
# New user, set password
obj.set_password(form.cleaned_data['password1'])
if obj: # Only proceed if obj is not None
obj.add_role('professor')
super().save_model(request, obj, form, change)
>>>>>>> develop
@admin.display(description='Phone Number') @admin.display(description='Phone Number')
def _phone_number(self, obj): def _phone_number(self, obj):

38
apps/account/admin/student.py

@ -4,6 +4,10 @@ from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import TokenProxy from rest_framework.authtoken.models import TokenProxy
from ajaxdatatable.admin import AjaxDatatable from ajaxdatatable.admin import AjaxDatatable
<<<<<<< HEAD
=======
from unfold.admin import TabularInline, StackedInline
>>>>>>> develop
from django.contrib import admin from django.contrib import admin
from apps.account.models import User from apps.account.models import User
@ -15,6 +19,7 @@ from django.contrib import messages
from apps.account.models import StudentUser, User from apps.account.models import StudentUser, User
<<<<<<< HEAD
@admin.register(StudentUser) @admin.register(StudentUser)
@ -22,6 +27,13 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable):
list_display = ( list_display = (
'device_id', 'email', 'fullname', 'user_type','last_login', 'date_joined', 'device_id', 'email', 'fullname', 'user_type','last_login', 'date_joined',
) )
=======
@admin.register(StudentUser)
class StudentUserAdmin(UserAdmin, AjaxDatatable):
list_display = (
'device_id', 'email', 'fullname', 'user_type', 'enrolled_courses_count', 'last_login', 'date_joined',
)
>>>>>>> develop
ordering = 'last_login', ordering = 'last_login',
readonly_fields = ('date_joined',) readonly_fields = ('date_joined',)
exclude = ('password', 'user_permissions') exclude = ('password', 'user_permissions')
@ -29,8 +41,11 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable):
(None, { (None, {
'classes': ('wide',), 'classes': ('wide',),
'fields': ('fullname', 'email', 'phone_number',), 'fields': ('fullname', 'email', 'phone_number',),
<<<<<<< HEAD
# 'description': 'Please provide the student details including full name, email, and phone number.', # 'description': 'Please provide the student details including full name, email, and phone number.',
=======
>>>>>>> develop
}), }),
('other', { ('other', {
'classes': ('wide',), 'classes': ('wide',),
@ -47,25 +62,48 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable):
fieldsets = ( fieldsets = (
(_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}),
(_('Permissions'), { (_('Permissions'), {
<<<<<<< HEAD
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups',), 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups',),
=======
'fields': ('is_active', 'groups',),
>>>>>>> develop
}), }),
(_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}),
) )
@admin.display(description='Phone Number') @admin.display(description='Phone Number')
def _phone_number(self, obj): def _phone_number(self, obj):
return obj.phone_number return obj.phone_number
<<<<<<< HEAD
def get_queryset(self, request): def get_queryset(self, request):
# محدود کردن نمایش فقط دانش‌آموزان # محدود کردن نمایش فقط دانش‌آموزان
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.filter(user_type=User.UserType.STUDENT) return qs.filter(user_type=User.UserType.STUDENT)
=======
@admin.display(description=_('Enrolled Courses'))
def enrolled_courses_count(self, obj):
"""نمایش تعداد دوره‌های شرکت کرده"""
count = obj.participated_courses.filter(is_active=True).count()
return f"{count} دوره"
def get_queryset(self, request):
# محدود کردن نمایش فقط دانش‌آموزان و بهینه‌سازی query
qs = super().get_queryset(request)
return qs.filter(user_type=User.UserType.STUDENT).prefetch_related('participated_courses')
>>>>>>> develop
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change: if not change:
obj.set_password(form.cleaned_data['password1']) obj.set_password(form.cleaned_data['password1'])
<<<<<<< HEAD
obj.user_type = User.UserType.STUDENT obj.user_type = User.UserType.STUDENT
=======
obj.add_role('student')
>>>>>>> develop
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)

381
apps/account/admin/user.py

@ -1,3 +1,4 @@
<<<<<<< HEAD
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.forms import UserChangeForm, UsernameField from django.contrib.auth.forms import UserChangeForm, UsernameField
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
@ -101,3 +102,383 @@ class AdminUserAdmin(UserAdmin, AjaxDatatable):
return obj.phone_number return obj.phone_number
admin.site.unregister(TokenProxy) admin.site.unregister(TokenProxy)
=======
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.contrib.auth.models import Group
from django.db import models
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.templatetags.static import static
from rest_framework.authtoken.models import TokenProxy
from unfold.admin import ModelAdmin, StackedInline
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.decorators import display
from unfold.forms import AdminPasswordChangeForm
from unfold.sections import TableSection
from unfold.contrib.filters.admin import RangeDateTimeFilter
# Import Models
from apps.account.models import User, ClientUser, StudentUser, ProfessorUser, LocationHistory
from apps.course.models import Participant
# Import Admin Sites from utils
from utils.admin import project_admin_site, dovoodi_admin_site
from apps.account.admin.location import LocationHistoryInline
# =========================================================
# 1. Base User Admin (Logic Shared by all User types)
# =========================================================
class UserAdmin(BaseUserAdmin, ModelAdmin):
form = UserChangeForm
add_form = UserCreationForm
change_password_form = AdminPasswordChangeForm
compressed_fields = False
list_before_template = "account/user_list_section.html"
list_display = ('fullname', 'email', 'is_active', 'display_date_joined',)
ordering = ("-id",)
search_fields = ('email', 'fullname', 'username',)
list_filter = [
"is_active",
"is_staff",
("last_login", RangeDateTimeFilter),
("date_joined", RangeDateTimeFilter),
]
inlines = [LocationHistoryInline]
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': (('fullname', 'email'), 'phone_number', 'birthdate', 'gender', 'avatar', 'skill', 'info'),
}),
(_('Location'), {
'fields': ('city', 'country'),
'classes': ('collapse',),
}),
(_('Password'), {
'fields': ('password1', 'password2'),
'classes': ('collapse',),
}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
'classes': ('collapse',),
}),
)
fieldsets = (
(None, {"fields": ("email", "fullname")}),
(_("Basic Information"), {
"fields": ("gender", "avatar", "phone_number", "birthdate", 'info', 'skill', "password"),
"classes": ["tab"],
}),
(_('Country & City'), {
'fields': ('city', 'country'),
"classes": ["tab"],
}),
(_('Device Information'), {
'fields': ('device_id', 'device_os', 'fcm', 'language',),
"classes": ["tab"],
}),
(_('Authentication'), {
'fields': ('display_auth_token',),
"classes": ["tab"],
}),
(_('Permissions'), {
'fields': ('user_type', 'is_active', 'is_staff', 'groups'),
"classes": ["tab"],
}),
(_('Important dates'), {
'fields': ('last_login', 'date_joined', 'deleted_at'),
"classes": ["tab"],
}),
)
formfield_overrides = {
models.TextField: {"widget": WysiwygWidget}
}
radio_fields = {"gender": admin.HORIZONTAL}
readonly_fields = ["last_login", "date_joined", "display_auth_token"]
@display(description=_("Date Joined"))
def display_date_joined(self, instance: User):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-"
@display(description=_("Last Login"))
def display_last_login(self, instance: User):
return instance.last_login.strftime("%Y-%m-%d %H:%M") if instance.last_login else "-"
@display(description=_("Authentication Token"))
def display_auth_token(self, instance: User):
from rest_framework.authtoken.models import Token
try:
token, created = Token.objects.get_or_create(user=instance)
return format_html('<code style="word-break: break-all;">{}</code>', token.key)
except Exception as e:
return format_html('<span class="error">{}</span>', str(e))
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(email__isnull=False)
# =========================================================
# 2. Specific User Type Admins
# =========================================================
class GuestUserAdmin(UserAdmin):
list_display = ('device_id', 'device_os', 'is_active', 'display_date_joined',)
def has_add_permission(self, request):
if '_popup' in request.GET and request.GET['_popup'] == '1':
return True
return False
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(email__isnull=True)
@display(description=_("Date Joined"))
def display_date_joined(self, instance: User):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-"
class StudentParticipantInline(StackedInline):
"""Inline to show courses a student has joined"""
model = Participant
extra = 0
readonly_fields = ('course', 'joined_date', 'course_status', 'course_professor')
fields = ('course', 'course_status', 'course_professor', 'joined_date', 'is_active')
verbose_name = _('Course Participation')
verbose_name_plural = _('Course Participations')
autocomplete_fields = ['course']
tab = True
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('course', 'course__professor')
@admin.display(description=_('Course Status'))
def course_status(self, obj):
if obj.course:
return obj.course.get_status_display()
return '-'
@admin.display(description=_('Professor'))
def course_professor(self, obj):
if obj.course and obj.course.professor:
return obj.course.professor.fullname or obj.course.professor.email
return '-'
def has_add_permission(self, request, obj=None):
return True
def has_change_permission(self, request, obj=None):
return True
def has_delete_permission(self, request, obj=None):
return True
class StudentUserAdmin(UserAdmin):
list_display = ('display_header', 'email', 'gender', 'display_age', 'courses_count')
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': (('fullname', 'email'), 'phone_number', 'avatar', 'birthdate', 'gender'),
}),
(_('Location'), {
'fields': (('city', 'country'),),
'classes': ('collapse',),
}),
(_('password'), {
'fields': ('password1', 'password2',),
'classes': ('collapse',),
}),
)
inlines = [StudentParticipantInline, LocationHistoryInline]
@display(description=_("Student"), header=True)
def display_header(self, instance: StudentUser):
avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
return [
instance.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 36,
"borderless": True,
},
]
@display(description=_("Age"))
def display_age(self, instance: StudentUser):
from datetime import date
if not instance.birthdate:
return "-"
today = date.today()
birthdate = instance.birthdate
age = today.year - birthdate.year - ((today.month, today.day) < (birthdate.month, birthdate.day))
formatted_date = birthdate.strftime("%Y-%m-%d")
return format_html('<span title="{}">{}</span>', f"Born on {formatted_date}", age)
@display(description=_("Courses"), dropdown=True)
def courses_count(self, instance: StudentUser):
total = instance.participated_courses.count()
items = []
for participant in instance.participated_courses.all():
course = participant.course
title = format_html(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
course.title,
course.id
)
items.append({"title": title})
if total == 0:
return "-"
return {
"title": f"{total} {_('courses')}",
"items": items,
"striped": True,
}
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related(
"participated_courses",
"participated_courses__course",
)
class CourseTableSection(TableSection):
verbose_name = _("Course Categories")
related_name = "courses"
height = 380
fields = ["title", "status", "edit_link"]
def edit_link(self, instance):
return format_html(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
instance.id
)
edit_link.short_description = _("Edit")
class ProfessorUserAdmin(UserAdmin):
list_display = ('display_header', 'email', 'courses_count')
list_sections = [CourseTableSection]
save_as = True
@display(description=_("Professor"), header=True)
def display_header(self, instance: StudentUser):
avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
return [
instance.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 50,
"borderless": True,
"squared": True,
},
]
@display(description=_("Courses"), dropdown=True)
def courses_count(self, instance: ProfessorUser):
total = instance.courses.count()
items = []
for course in instance.courses.all():
title = format_html(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
course.title,
course.id
)
items.append({"title": title})
if total == 0:
return "-"
return {
"title": f"{total} {_('courses')}",
"items": items,
"striped": True,
}
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("courses")
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
list_display = ('name', 'permissions_count')
search_fields = ('name',)
ordering = ('name',)
filter_horizontal = ('permissions',)
fieldsets = (
(None, {'fields': ('name',)}),
(_('Permissions'), {'fields': ('permissions',), 'classes': ['tab']}),
)
@display(description=_("Permissions"))
def permissions_count(self, obj):
count = obj.permissions.count()
return f"{count} {_('permissions')}" if count > 0 else "-"
# =========================================================
# 3. Registrations (SAFE METHOD)
# =========================================================
# A. DEFAULT DJANGO ADMIN (SAFE REGISTRATION)
# This is required because plugins like 'django-filer' expect User to be registered here.
try:
admin.site.unregister(User)
except admin.sites.NotRegistered:
pass
try:
admin.site.register(User, UserAdmin)
except admin.sites.AlreadyRegistered:
pass
# B. PROJECT ADMIN SITE (Imam Javad)
project_admin_site.register(User, UserAdmin)
project_admin_site.register(ClientUser, GuestUserAdmin)
project_admin_site.register(StudentUser, StudentUserAdmin)
project_admin_site.register(ProfessorUser, ProfessorUserAdmin)
project_admin_site.register(Group, GroupAdmin)
# C. DOVOODI ADMIN SITE
dovoodi_admin_site.register(User, UserAdmin)
dovoodi_admin_site.register(ClientUser, GuestUserAdmin)
dovoodi_admin_site.register(Group, GroupAdmin)
# D. Unregister TokenProxy safely (Cleaner UI)
try:
admin.site.unregister(TokenProxy)
except admin.sites.NotRegistered:
pass
>>>>>>> develop

28
apps/account/management/commands/assign_professor_slugs.py

@ -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).")
)

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

@ -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}')

19
apps/account/manager.py

@ -12,25 +12,25 @@ class UserManager(BaseUserManager):
def create_user( def create_user(
self, self,
email: str = None, email: str = None,
fullname: str = None,
# fullname: str = None,
password: str = None, password: str = None,
**extra_fields **extra_fields
): ):
email = UserManager.normalize_email(email) email = UserManager.normalize_email(email)
user = self.model( user = self.model(
email=email, email=email,
fullname=fullname,
# fullname=fullname,
**extra_fields **extra_fields
) )
user.set_password(password) user.set_password(password)
user.save(using=self._db) user.save(using=self._db)
return user return user
def create_superuser(self, email, fullname, password):
def create_superuser(self, email, password, **extra_fields):
user = self.create_user( user = self.create_user(
email=email, email=email,
fullname=fullname,
password=password, password=password,
**extra_fields
) )
user.is_admin = True user.is_admin = True
user.is_staff = True user.is_staff = True
@ -42,6 +42,7 @@ class UserManager(BaseUserManager):
def change_user_type(self, user, new_user_type): def change_user_type(self, user, new_user_type):
"""تغییر نوع کاربر - deprecated، از add_role استفاده کنید"""
group_name = f"{new_user_type.capitalize()} Group" group_name = f"{new_user_type.capitalize()} Group"
if user.user_type != new_user_type and not user.groups.filter(name=group_name).exists(): if user.user_type != new_user_type and not user.groups.filter(name=group_name).exists():
@ -52,6 +53,16 @@ class UserManager(BaseUserManager):
return user return user
return None return None
def add_user_role(self, user, role_name):
"""اضافه کردن نقش جدید به کاربر بدون حذف نقش‌های قبلی"""
user.add_role(role_name)
return user
def remove_user_role(self, user, role_name):
"""حذف نقش خاص از کاربر"""
user.remove_role(role_name)
return user
class ProfessorUserManager(UserManager): class ProfessorUserManager(UserManager):

114
apps/account/middleware/admin_access.py

@ -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')

59
apps/account/migrations/0001_initial.py

@ -1,10 +1,11 @@
# Generated by Django 3.2.4 on 2024-11-19 08:43
# Generated by Django 5.1.8 on 2025-04-03 00:05
import dj_language.field import dj_language.field
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields
import utils.validators import utils.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -12,8 +13,8 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('dj_language', '0002_auto_20220120_1344'),
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
('dj_language', '0002_auto_20220120_1344'),
] ]
operations = [ operations = [
@ -24,27 +25,34 @@ class Migration(migrations.Migration):
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(help_text="Enter the user's email address.", max_length=254, unique=True, verbose_name='Email Address')),
('fullname', models.CharField(help_text='Enter the full name of the user.', max_length=255, verbose_name='Full Name')),
('birthdate', models.DateField(verbose_name='birthdate')),
('username', models.CharField(blank=True, max_length=150, null=True, unique=True)),
('email', models.EmailField(blank=True, help_text="Enter the user's email address.", max_length=254, null=True, unique=True, verbose_name='Email Address')),
('fullname', models.CharField(blank=True, help_text='Enter the full name of the user.', max_length=255, null=True, verbose_name='Full Name')),
('birthdate', models.DateField(blank=True, null=True, verbose_name='birthdate')),
('avatar', models.ImageField(blank=True, null=True, upload_to='users/avatars/%Y/%m/')), ('avatar', models.ImageField(blank=True, null=True, upload_to='users/avatars/%Y/%m/')),
('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, unique=True, validators=[utils.validators.validate_possible_number], verbose_name='phone')),
('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='phone')),
('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female')], help_text="Select the user's gender.", max_length=20, null=True, verbose_name='Gender')), ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female')], help_text="Select the user's gender.", max_length=20, null=True, verbose_name='Gender')),
('user_type', models.CharField(choices=[('professor', 'Professor'), ('client', 'Client'), ('student', 'Student'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', help_text='Type of the user.', max_length=20, verbose_name='User Type')), ('user_type', models.CharField(choices=[('professor', 'Professor'), ('client', 'Client'), ('student', 'Student'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', help_text='Type of the user.', max_length=20, verbose_name='User Type')),
('date_joined', models.DateTimeField(auto_now_add=True, help_text='The date and time the user registered.', verbose_name='Date Joined')),
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')),
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='country')),
('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='device id')), ('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='device id')),
('device_os', models.CharField(choices=[('android', 'android'), ('apple', 'apple')], max_length=16, null=True)),
('fcm', models.CharField(blank=True, max_length=512, null=True)), ('fcm', models.CharField(blank=True, max_length=512, null=True)),
('date_joined', models.DateTimeField(auto_now_add=True, help_text='The date and time the user registered.', verbose_name='Date Joined')),
('is_staff', models.BooleanField(default=False)), ('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='Active')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='Active')),
('deleted_at', models.DateTimeField(blank=True, null=True)), ('deleted_at', models.DateTimeField(blank=True, null=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('info', models.TextField(blank=True, null=True, verbose_name='Info')),
('skill', models.CharField(blank=True, max_length=512, null=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('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')), ('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')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],
options={ options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'verbose_name': 'All Users',
'verbose_name_plural': 'All Users',
'ordering': ('-id',), 'ordering': ('-id',),
'unique_together': {('email', 'device_id')},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -113,4 +121,31 @@ class Migration(migrations.Migration):
}, },
bases=('account.user',), bases=('account.user',),
), ),
migrations.CreateModel(
name='LoginHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lat', models.FloatField(blank=True, null=True, verbose_name='lat')),
('lon', models.FloatField(blank=True, null=True, 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')),
('ip', models.CharField(max_length=255, null=True)),
('timezone', models.CharField(blank=True, max_length=100, null=True)),
('at_time', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_history', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='title')),
('message', models.TextField(max_length=512, verbose_name='message')),
('is_read', models.BooleanField(default=False, verbose_name='is read')),
('service', models.CharField(choices=[('imam-javad', 'Imam Javad'), ('doboodi', 'Doboodi')], default='imam-javad', max_length=20, verbose_name='service')),
('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='updated at')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
),
] ]

20
apps/account/migrations/0002_alter_user_phone_number.py

@ -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'),
),
]

30
apps/account/migrations/0003_locationhistory.py

@ -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)),
],
),
]

18
apps/account/migrations/0004_alter_user_avatar.py

@ -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/'),
),
]

17
apps/account/migrations/0005_alter_user_unique_together.py

@ -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',)},
),
]

45
apps/account/migrations/0006_auto_20251006_1101.py

@ -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),
]

17
apps/account/migrations/0007_user_user_agent.py

@ -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"),
),
]

22
apps/account/migrations/0008_loginhistory_device_os_loginhistory_user_agent.py

@ -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"),
),
]

17
apps/account/migrations/0009_user_client_ip.py

@ -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"),
),
]

21
apps/account/migrations/0010_alter_user_device_os.py

@ -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,
),
),
]

197
apps/account/models/user.py

@ -1,7 +1,9 @@
import random import random
import secrets
from dj_language.field import LanguageField from dj_language.field import LanguageField
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
@ -14,6 +16,7 @@ class User(AbstractUser):
class DeviceOs(models.TextChoices): class DeviceOs(models.TextChoices):
android = 'android', 'android' android = 'android', 'android'
apple = 'apple', 'apple' apple = 'apple', 'apple'
web = 'web', 'web'
class UserType(models.TextChoices): class UserType(models.TextChoices):
PROFESSOR = 'professor', 'Professor' PROFESSOR = 'professor', 'Professor'
@ -33,8 +36,14 @@ class User(AbstractUser):
fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.", null=True, blank=True) fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.", null=True, blank=True)
birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True)
avatar = models.ImageField(null=True, blank=True, upload_to='users/avatars/%Y/%m/')
phone_number = PhoneNumberField(unique=True, validators=[validate_possible_number], null=True, blank=True, verbose_name=_('phone'))
avatar = models.ImageField(max_length=512, null=True, blank=True, upload_to='users/avatars/%Y/%m/')
phone_number = PhoneNumberField(
validators=[validate_possible_number],
null=True,
blank=True,
verbose_name=_('Phone Number'),
help_text="e.g., +49 151 12345678"
)
language = LanguageField(null=True) language = LanguageField(null=True)
gender = models.CharField(max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text="Select the user's gender.") gender = models.CharField(max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text="Select the user's gender.")
@ -46,8 +55,12 @@ class User(AbstractUser):
device_id = models.CharField(verbose_name=_('device id'), max_length=255, null=True, blank=True) device_id = models.CharField(verbose_name=_('device id'), max_length=255, null=True, blank=True)
device_os = models.CharField(choices=DeviceOs.choices, null=True, max_length=16) device_os = models.CharField(choices=DeviceOs.choices, null=True, max_length=16)
user_agent = models.TextField(verbose_name=_('user agent'), null=True, blank=True)
client_ip = models.TextField(verbose_name=_('client ip'), null=True, blank=True)
fcm = models.CharField(max_length=512, null=True, blank=True) fcm = models.CharField(max_length=512, null=True, blank=True)
slug = models.SlugField(max_length=255, unique=True, null=True, blank=True)
experience_years = models.PositiveIntegerField(default=0, verbose_name=_('Experience years'))
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.") is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.")
deleted_at = models.DateTimeField(null=True, blank=True) deleted_at = models.DateTimeField(null=True, blank=True)
@ -57,13 +70,13 @@ class User(AbstractUser):
EMAIL_FIELD = "email" EMAIL_FIELD = "email"
USERNAME_FIELD = "username"
USERNAME_FIELD = "email"
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
def __str__(self): def __str__(self):
username = self.email or self.fullname or self.device_id username = self.email or self.fullname or self.device_id
return f"{username}-({self.user_type})"
return f"{username}"
def soft_delete(self): def soft_delete(self):
self.deleted_at = timezone.now() self.deleted_at = timezone.now()
@ -72,12 +85,17 @@ class User(AbstractUser):
number = str(random.randint(1000000000, 9999999999)) number = str(random.randint(1000000000, 9999999999))
self.phone_number = f'{self.phone_number}:deleted{number}' self.phone_number = f'{self.phone_number}:deleted{number}'
self.email = f'{self.email}:deleted{number}' if self.email else None self.email = f'{self.email}:deleted{number}' if self.email else None
self.device_id = f'{self.device_id}:deleted{number}' if self.device_id else None
self.save() self.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.username = self.email self.username = self.email
if User.objects.filter(username=self.email).count():
if User.objects.filter(username=self.email).exclude(pk=self.pk).exists():
self.username = f'{self.email}:{self.id}' self.username = f'{self.email}:{self.id}'
if self.user_type == self.UserType.PROFESSOR:
self._ensure_professor_slug()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_full_name(self): def get_full_name(self):
@ -97,16 +115,168 @@ class User(AbstractUser):
else: else:
return self.UserType.CLIENT return self.UserType.CLIENT
@property
def primary_role(self):
"""نقش اصلی کاربر بر اساس اولویت"""
if self.groups.filter(name="Professor Group").exists():
return self.UserType.PROFESSOR
elif self.groups.filter(name="Student Group").exists():
return self.UserType.STUDENT
elif self.groups.filter(name="Admin Group").exists():
return self.UserType.ADMIN
elif self.groups.filter(name="Super Admin Group").exists():
return self.UserType.SUPER_ADMIN
else:
return self.UserType.CLIENT
def has_role(self, role_name):
"""چک کردن داشتن نقش خاص"""
if isinstance(role_name, str):
# اگر نام نقش به صورت string داده شده
group_name = f"{role_name.capitalize()} Group"
else:
# اگر از enum استفاده شده
group_name = f"{role_name.value.capitalize()} Group"
return self.groups.filter(name=group_name).exists()
def add_role(self, role_name):
"""اضافه کردن نقش جدید بدون حذف نقش‌های قبلی"""
from django.contrib.auth.models import Group
if isinstance(role_name, str):
group_name = f"{role_name.capitalize()} Group"
else:
group_name = f"{role_name.value.capitalize()} Group"
group, created = Group.objects.get_or_create(name=group_name)
self.groups.add(group)
# بروزرسانی user_type اگر نقش جدید اولویت بالاتری دارد
if role_name in ['professor', self.UserType.PROFESSOR] and self.user_type != self.UserType.PROFESSOR:
self.user_type = self.UserType.PROFESSOR
self.save()
elif role_name in ['student', self.UserType.STUDENT] and self.user_type == self.UserType.CLIENT:
self.user_type = self.UserType.STUDENT
self.save()
def remove_role(self, role_name):
"""حذف نقش خاص"""
from django.contrib.auth.models import Group
if isinstance(role_name, str):
group_name = f"{role_name.capitalize()} Group"
else:
group_name = f"{role_name.value.capitalize()} Group"
try:
group = Group.objects.get(name=group_name)
self.groups.remove(group)
# بروزرسانی user_type بر اساس نقش‌های باقی‌مانده
self.user_type = self.primary_role
self.save()
except Group.DoesNotExist:
pass
def get_all_roles(self):
"""دریافت لیست تمام نقش‌های کاربر"""
return [group.name.replace(' Group', '').lower()
for group in self.groups.all()]
def can_teach_course(self):
"""آیا می‌تواند دوره تدریس کند؟"""
# اولویت اول: staff یا admin
if self.is_staff or self.has_role('admin') or self.has_role('super_admin'):
return True
# اولویت دوم: professor
return self.has_role('professor')
def can_enroll_course(self):
"""آیا می‌تواند در دوره ثبت‌نام کند؟"""
return True # همه می‌توانند دانش‌آموز باشند
def can_manage_course(self, course=None):
"""آیا می‌تواند دوره خاصی را مدیریت کند؟"""
# اولویت اول: staff یا admin - دسترسی کامل
if self.is_staff or self.has_role('admin') or self.has_role('super_admin'):
return True
# اولویت دوم: professor - فقط دوره‌های خودش
if course and self.has_role('professor'):
return course.professor == self
return False
def ensure_professor_profile(self, commit: bool = True) -> bool:
"""تضمین می‌کند کاربر نقش استاد دارد، اسلاگ دارد و در گروه استاد است."""
updated_fields = set()
if self.user_type != self.UserType.PROFESSOR:
self.user_type = self.UserType.PROFESSOR
updated_fields.add('user_type')
if not self.slug:
self._ensure_professor_slug()
if self.slug:
updated_fields.add('slug')
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name="Professor Group")
group_added = False
if not self.groups.filter(id=group.id).exists():
self.groups.add(group)
group_added = True
if commit and updated_fields:
self.save(update_fields=list(updated_fields))
return bool(updated_fields or group_added)
def _ensure_professor_slug(self):
if self.slug:
return
base_candidates = [
self.fullname,
(self.email.split('@')[0] if self.email else None),
self.username,
]
for candidate in base_candidates:
if candidate:
self.slug = self._build_unique_slug(candidate)
if self.slug:
return
self.slug = self._build_unique_slug(f"professor-{secrets.token_hex(4)}")
def _build_unique_slug(self, seed: str) -> str:
base_slug = slugify(seed, allow_unicode=True)
if not base_slug:
base_slug = f"professor-{secrets.token_hex(4)}"
slug = base_slug
counter = 1
qs = User.objects.all()
if self.pk:
qs = qs.exclude(pk=self.pk)
while qs.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
return slug[:255]
class Meta: class Meta:
ordering = ("-id",) ordering = ("-id",)
verbose_name = "All Users" verbose_name = "All Users"
verbose_name_plural = "All Users" verbose_name_plural = "All Users"
unique_together = ( unique_together = (
'email', 'device_id'
'email',
) )
class LoginHistory(models.Model): class LoginHistory(models.Model):
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history') user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history')
lat = models.FloatField(verbose_name=_('lat'), null=True, blank=True) lat = models.FloatField(verbose_name=_('lat'), null=True, blank=True)
@ -115,4 +285,19 @@ class LoginHistory(models.Model):
city = models.CharField(max_length=255, verbose_name=_('city'), null=True, blank=True) city = models.CharField(max_length=255, verbose_name=_('city'), null=True, blank=True)
ip = models.CharField(max_length=255, null=True) ip = models.CharField(max_length=255, null=True)
timezone = models.CharField(max_length=100, null=True, blank=True) timezone = models.CharField(max_length=100, null=True, blank=True)
user_agent = models.TextField(verbose_name=_('user agent'), null=True, blank=True)
device_os = models.CharField(max_length=16, null=True, blank=True)
at_time = models.DateTimeField(auto_now_add=True) at_time = models.DateTimeField(auto_now_add=True)
class LocationHistory(models.Model):
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='location_history')
lat = models.FloatField(verbose_name=_('lat'))
lon = models.FloatField(verbose_name=_('lon'))
country = models.CharField(max_length=255, verbose_name=_('country'), null=True, blank=True)
city = models.CharField(max_length=255, verbose_name=_('city'), null=True, blank=True)
selected_manually = models.BooleanField(null=True, blank=True)
ip = models.CharField(max_length=255, null=True, blank=True)
timezone = models.CharField(null=True, blank=True, max_length=60)
at_time = models.DateTimeField(auto_now_add=True)

5
apps/account/serializers/__init__.py

@ -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

11
apps/account/serializers/auth.py

@ -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

37
apps/account/serializers/location_history.py

@ -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)

138
apps/account/serializers/user.py

@ -1,4 +1,8 @@
<<<<<<< HEAD
=======
>>>>>>> develop
from rest_framework import serializers from rest_framework import serializers
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
@ -14,6 +18,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) password = serializers.CharField(write_only=True, required=False, validators=[validate_password])
fullname = serializers.CharField(required=False) fullname = serializers.CharField(required=False)
gender = serializers.ChoiceField( gender = serializers.ChoiceField(
<<<<<<< HEAD
choices=User.GenderChoices.choices, choices=User.GenderChoices.choices,
required=False, required=False,
help_text="Select the user's gender." help_text="Select the user's gender."
@ -23,6 +28,33 @@ class UserProfileSerializer(serializers.ModelSerializer):
model = User model = User
fields = ['id', 'device_id', 'fcm', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender'] fields = ['id', 'device_id', 'fcm', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender']
read_only_fields = ['email', 'info', 'skill', 'device_id'] read_only_fields = ['email', 'info', 'skill', 'device_id']
=======
choices=User.GenderChoices.choices,
required=False,
help_text="Select the user's gender."
)
fcm = serializers.CharField(required=False, help_text="Firebase Cloud Messaging token.")
saved_location = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'device_id', 'fcm', 'fullname', 'slug', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender', 'saved_location']
read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location']
def get_saved_location(self, obj):
last_location = obj.location_history.order_by('-at_time').first()
if last_location:
return {
'lat': last_location.lat,
'lon': last_location.lon,
'city': last_location.city,
'country': last_location.country,
'timezone': last_location.timezone,
'selected_manually': last_location.selected_manually,
'at_time': last_location.at_time,
}
return None
>>>>>>> develop
# def validate_email(self, value): # def validate_email(self, value):
# if User.objects.filter(email=value).exists(): # if User.objects.filter(email=value).exists():
@ -30,17 +62,36 @@ class UserProfileSerializer(serializers.ModelSerializer):
# return value # return value
def update(self, instance, validated_data): def update(self, instance, validated_data):
<<<<<<< HEAD
=======
# Pop the password from the data to handle it separately
password = validated_data.pop('password', None)
# Use the default update logic for all other fields
>>>>>>> develop
for attr, value in validated_data.items(): for attr, value in validated_data.items():
if value is not None: if value is not None:
setattr(instance, attr, value) setattr(instance, attr, value)
<<<<<<< HEAD
=======
# If a new password was provided, hash and set it correctly
if password:
instance.set_password(password)
>>>>>>> develop
instance.save() instance.save()
return instance return instance
class UserRegisterSerializer(serializers.ModelSerializer): class UserRegisterSerializer(serializers.ModelSerializer):
<<<<<<< HEAD
fcm = serializers.CharField(required=False) fcm = serializers.CharField(required=False)
device_id = serializers.CharField(required=True) device_id = serializers.CharField(required=True)
=======
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True)
device_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, write_only=True)
>>>>>>> develop
email = serializers.EmailField() email = serializers.EmailField()
class Meta: class Meta:
@ -49,6 +100,7 @@ class UserRegisterSerializer(serializers.ModelSerializer):
extra_kwargs = { extra_kwargs = {
'fullname': {'required': True,}, 'fullname': {'required': True,},
'email': {'required': True,}, 'email': {'required': True,},
<<<<<<< HEAD
'device_id': {'required': True,}, 'device_id': {'required': True,},
} }
@ -56,6 +108,23 @@ class UserRegisterSerializer(serializers.ModelSerializer):
if User.objects.filter(email=value).exists(): if User.objects.filter(email=value).exists():
raise serializers.ValidationError("This email is already registered.") raise serializers.ValidationError("This email is already registered.")
return value return value
=======
}
def create(self, validated_data):
device_id = validated_data.pop('device_id', None)
user = super().create(validated_data)
if device_id:
user.device_id = device_id
user.save()
return user
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
>>>>>>> develop
@ -64,7 +133,16 @@ class UserVerifySerializer(serializers.Serializer):
email = serializers.EmailField() email = serializers.EmailField()
device_id = serializers.CharField(max_length=255, required=False) device_id = serializers.CharField(max_length=255, required=False)
<<<<<<< HEAD
=======
def validate_email(self, value):
"""
Normalize the email to ensure the Redis key matches correctly.
"""
return User.objects.normalize_email(value)
>>>>>>> develop
class UserLoginSerializer(serializers.Serializer): class UserLoginSerializer(serializers.Serializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
@ -82,6 +160,15 @@ class UserLoginSerializer(serializers.Serializer):
# data.pop('fcm', None) # data.pop('fcm', None)
# data.pop('device_id', None) # data.pop('device_id', None)
return data return data
<<<<<<< HEAD
=======
def validate_email(self, value):
"""
Normalize email for case-insensitive login.
"""
return User.objects.normalize_email(value)
>>>>>>> develop
# class UserLoginSerializer(serializers.Serializer): # class UserLoginSerializer(serializers.Serializer):
# password = serializers.CharField(write_only=True) # password = serializers.CharField(write_only=True)
@ -97,6 +184,7 @@ class UserLoginSerializer(serializers.Serializer):
<<<<<<< HEAD
class UserRecoverPasswordSerializer(serializers.ModelSerializer): class UserRecoverPasswordSerializer(serializers.ModelSerializer):
email = serializers.EmailField() email = serializers.EmailField()
@ -108,6 +196,31 @@ class UserRecoverPasswordSerializer(serializers.ModelSerializer):
} }
=======
# class UserRecoverPasswordSerializer(serializers.ModelSerializer):
# email = serializers.EmailField()
# class Meta:
# model = User
# fields = ['email',]
# extra_kwargs = {
# 'email': {'required': True,},
# }
class UserRecoverPasswordSerializer(serializers.Serializer):
"""
Validates that an email is provided and is in a valid format
without checking for database uniqueness.
"""
email = serializers.EmailField(required=True)
def validate_email(self, value):
"""
Normalize the email address to ensure case-insensitive lookups.
"""
return User.objects.normalize_email(value)
>>>>>>> develop
class UserResetPasswordSerializer(serializers.ModelSerializer): class UserResetPasswordSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
@ -140,3 +253,28 @@ class UserGuestSerializer(serializers.ModelSerializer):
return data return data
<<<<<<< HEAD
=======
class WebUserGuestSerializer(serializers.ModelSerializer):
user_agent = serializers.CharField(required=False, allow_null=True, allow_blank=True)
client_ip = serializers.CharField(required=False, allow_null=True, allow_blank=True)
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class Meta:
model = User
fields = ['user_agent', 'client_ip', 'timezone', 'device_id', 'device_os']
def validate(self, data):
# Ensure device_id is provided (generated by view)
if not data.get('device_id'):
raise serializers.ValidationError({"device_id": "Device ID is required for web guest users."})
return data
class UserFCMSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['fcm']
>>>>>>> develop

29
apps/account/serializers/user_web.py

@ -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

101
apps/account/tasks.py

@ -1,9 +1,108 @@
import time import time
from config.settings import base as settings from config.settings import base as settings
from celery import shared_task from celery import shared_task
import requests import requests
import json import json
import logging
# import firebase_admin
# from firebase_admin import credentials, messaging
# import firebase_admin
# from firebase_admin import credentials, messaging
from google.oauth2 import service_account
import google.auth.transport.requests
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# تنظیمات Firebase
data = {
"type": "service_account",
"project_id": "imamjavad-25c31",
"private_key_id": "1edc90fb80a335809c4b04a713403355ff4e8bd0",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQCM57lia6vNNzJL\nYBwFcx49sFPXtrYKkrRhtDN13EOnF2j+y8vlwtqYR6P7HB1l10GyHx3mlN8XpYXN\n24yrTsK6WugqPdWGl/z/BgqHximH+v4NCPQTBr3lemHbTXlEkhNtmaf9zM8IzsXP\nEzD4z5u9hy3AgfZdHKO0isTvxRTuTlpTKU3PQDwiIjfb6Bk8IjfjrDQqWbHLC4am\nidwM5F+L8ecAyhVe/G7IXAflqyi4zVM/hM5FiAknA5FmyfGd9HCxaLkw3Dqtnof8\nqflJmZp0vptT3Lte7ObeUEMoRoT0bZt6DbMBI+w19OIBu0ne0OjLu/1z4CFYoR+r\nAeWGvWCHAgMBAAECgf9HnQx/FY90oO5gtiLdE/pnJxqSMtjEhufRazaDd4vOYKXD\nhLQ5EkFcsij66PnPHiZiC+BfbUpnSIAqrmsliXBSYv4OCELTJU/FovcMfHG7qtU7\nIBjsrw64ISXT+ow1+EEEAWm1eA0WwjmOBTL7CTPJA3l2QXrYu5ki8IDuP1i5UwKu\nSR3kW0+BfsQG0z2q00AjqGnFV9IuDDjcAvu2ojwanM/H+eGB+I/dtpqe87KhbBZ9\nFuKCdYNgRa3Z76mU/2jSyGQ9eyXCX0x0vKpPavkbfir7mJcvCrp+3z0h8ot0u1Mi\nj7IJd9Ot37qUj09obXyInYk8Vnj46lj8+QjdgAECgYEAxj9Fmu9oSgLBLsuYU0kU\nmUcl0HOv3UllKAYX+8Z6L/dR1KKsfSoRWQoyGE1TxXsR/uJ4uQJJZLlHMVSw3mz3\nmOHep3F5TNSM6cfJnh5/NSMoAklOzRZxW/UELcu9vaR+e1QSgBMaNmc3b483pbfs\neVD3CPPWFt2A4lI3Y77jQAECgYEAtfQLcrBYv+SEIrVML6pXrHRC19RwCzmLyC69\n07LyRG2THu26IhOK+aSzLT5FRXTOP1VD+FHfD+AOr2d1oc2HrmgxU0mVio93KSW4\nxDrmBrej1DmVjB7LSqxu7chiD/lBUdFh2Fam8dsiTQtqR02qfcQGLynvEb2yTUbj\n0lTmoIcCgYBWZ7VatgXqXBD+6FXX1v5XYB8nH4UDGb4xF5bUcclHpq/P0acEVpWB\nDWSQGwPsCpvpT6P2XvzGHcrdwV/lUfEIfUmiCV8pEWrpad6CQCCJdG03sePal3GI\n9t1/aFGmmk9WSWpWz/yYwZvzz6QdYnB638ML79rb1GccPWFO5CAAAQKBgA4+Hi9K\nEohi0N0Op/oLMXW0XA8c9/BI/uIalo1dso0crql7HljQgs5r0AK4nx+CtypJ+FoV\nvoo1lbCxPon91qMWUNYeKnCALmmwJDhoC912voI8R7KCLpOXz88ZImPxtOU8qJYQ\nolzINHUncZhHQhM6JunGNIqE+NIHvImYT709AoGALJGUb9jAg/QpSoFKlbp4xrEA\n3G/caXeB+lE19KGZxgADBbWsUsfMI7CxnROZFobCzTdhIE6N+LaAFX/6rn0P6Nf9\nN6w8//442RjkWxtmDgw7lCykXwyLSfrP3Dbzd78gGIBqngPTej9JCc7WJYnnN75M\n5TGjxvmxYqR231/L/p0=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@imamjavad-25c31.iam.gserviceaccount.com",
"client_id": "103207313184637638669",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40imamjavad-25c31.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
PROJECT_ID = 'imamjavad-25c31'
BASE_URL = 'https://fcm.googleapis.com'
SCOPES = ['https://www.googleapis.com/auth/firebase.messaging']
FCM_ENDPOINT = f'v1/projects/{PROJECT_ID}/messages:send'
FCM_URL = f'{BASE_URL}/{FCM_ENDPOINT}'
def _get_access_token():
"""Retrieve a valid access token that can be used to authorize requests.
:return: Access token.
"""
credentials = service_account.Credentials.from_service_account_info(
data, scopes=SCOPES)
request = google.auth.transport.requests.Request()
credentials.refresh(request)
return credentials.token
# @shared_task
async def send_notification(ids: list, title: str = None, body: str = None, data=None,
extra_notification_kwargs: dict = None) -> list:
if not ids:
return []
chunked_ids = [ids[i:i + 500] for i in range(0, len(ids), 500)]
responses = []
for chunk in chunked_ids:
access_token = _get_access_token()
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
payload = {
'message': {
'token': chunk[0],
'notification': {
'title': title,
'body': body,
},
'data': {k: str(v) for k, v in (data or {}).items()},
'android': {
'priority': 'high',
'notification': {
'title': title,
'body': body,
# 'sound': 'incoming_call_sound',
'color': '#06EEBD',
# 'channel_id': 'incoming_call_channel',
'visibility': 'public',
},
},
}
}
# Send the POST request to FCM API
print(f'=========(send-notif)===******')
response = requests.post(FCM_URL, headers=headers, json=payload)
if response.status_code == 200:
logger.warning('Successfully sent message:', response.json())
responses.append(response.json())
else:
responses.append({'status': 'error', 'message': ""})
logger.error(f'Failed to send message notif')
return responses
@shared_task @shared_task
def send_otp_code(phone_number, code): def send_otp_code(phone_number, code):

40
apps/account/templates/account/group_help_text.html

@ -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>

800
apps/account/templates/account/json_editor_field.html

@ -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>

33
apps/account/templates/account/user_list_section.html

@ -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>

240
apps/account/tests/test_multiple_roles.py

@ -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))

10
apps/account/urls.py

@ -11,13 +11,17 @@ urlpatterns = [
# URL for user registration, accepts POST requests for creating new user instances. # URL for user registration, accepts POST requests for creating new user instances.
path('register/', views.UserRegisterView.as_view(), name='user-register'), path('register/', views.UserRegisterView.as_view(), name='user-register'),
path('web/register/', views.WebUserRegisterView.as_view(), name='web-user-register'),
path('verify/', views.UserVerifyView.as_view(), name='user-verify'), path('verify/', views.UserVerifyView.as_view(), name='user-verify'),
path('login/', views.UserLoginView.as_view(), name='user-login'), path('login/', views.UserLoginView.as_view(), name='user-login'),
path('guest/', views.UserGuestView.as_view(), name='user-guest'), path('guest/', views.UserGuestView.as_view(), name='user-guest'),
path('web/guest/', views.WebUserGuestView.as_view(), name='user-guest'),
path('exchange-token/', views.ExchangeTokenAPIView.as_view(), name='exchange-token'),
path('location-update/', views.LocationHistoryView.as_view(), name='user-location-history'),
path('location-info/', views.RegionInfoView.as_view(), name='region-info'),
path('geolocation/coordinates/', views.ReverseGeolocationAPIView.as_view(), name='geolocation-by-coordinates'),
# path('notif/', views.NotificationListView.as_view(), name='user-notif'),
# path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'),
# # URL to get user details, supports GET for fetching user profile based on the provided token. # # URL to get user details, supports GET for fetching user profile based on the provided token.
@ -28,11 +32,13 @@ urlpatterns = [
path('notif/', views.NotificationListView.as_view(), name='user-notif'), path('notif/', views.NotificationListView.as_view(), name='user-notif'),
path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'),
path('notif/send/', views.SendNotificationView.as_view(), name='user-send-notif'),
# # URL to update user details, supports PUT to update user fields like phone or email given a token. # # URL to update user details, supports PUT to update user fields like phone or email given a token.
path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), path('profile/update/', views.UserUpdateView.as_view(), name='user-update'),
# # delete user account # # delete user account
path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'),
path('update-fcm/', views.UpdateFCMView.as_view(), name='update-fcm'),
] ]

6
apps/account/views/__init__.py

@ -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

126
apps/account/views/auth.py

@ -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)

358
apps/account/views/location_history.py

@ -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)

60
apps/account/views/notification.py

@ -5,8 +5,13 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from apps.account.serializers import NotificationSerializer, NotificationSendSerializer from apps.account.serializers import NotificationSerializer, NotificationSendSerializer
<<<<<<< HEAD
from apps.account.models import Notification from apps.account.models import Notification
# from apps.account.fcm_notification import send_notification # from apps.account.fcm_notification import send_notification
=======
from apps.account.models import Notification, User
from apps.account.tasks import send_notification
>>>>>>> develop
@ -103,3 +108,58 @@ class NotificationReadAllView(generics.GenericAPIView):
<<<<<<< HEAD
=======
class SendNotificationView(generics.GenericAPIView):
@swagger_auto_schema(
operation_description="Send a notification to a user by user_id.",
tags=['Notifications'],
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['user_id', 'title', 'body'],
properties={
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the user to send notification to'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Notification title'),
'body': openapi.Schema(type=openapi.TYPE_STRING, description='Notification body'),
'data': openapi.Schema(type=openapi.TYPE_OBJECT, description='Additional data payload', default={'slam': 'qatreh'}),
},
),
responses={
200: openapi.Response('Notification sent successfully.'),
400: openapi.Response('FCM token not available for this user.'),
404: openapi.Response('User not found.'),
500: openapi.Response('Internal server error.'),
}
)
def post(self, request, *args, **kwargs):
user_id = request.data.get('user_id', 1)
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response({'error': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
notification_title = request.data.get('title', 'test qatreh')
notification_body = request.data.get('body', 'test qatreh body')
data_payload = request.data.get('data', {'slam':'qatreh'})
fcm_token = user.fcm # Ensure that 'fcm' is a field in your User model
if not fcm_token:
return Response({
'error': 'FCM token not available for this user.'
}, status=status.HTTP_400_BAD_REQUEST)
try:
send_notification([fcm_token], notification_title, notification_body, data_payload)
return Response({
'message': 'Notification sent successfully.'
}, status=status.HTTP_200_OK)
except Exception as e:
return Response({
'error': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
>>>>>>> develop

225
apps/account/views/user.py

@ -23,7 +23,12 @@ from rest_framework.exceptions import ValidationError
from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException
from apps.account.models import User from apps.account.models import User
<<<<<<< HEAD
from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer
=======
from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer,UserFCMSerializer,WebUserGuestSerializer
from apps.account.serializers.user_web import WebUserRegisterSerializer
>>>>>>> develop
from utils.redis import RedisManager from utils.redis import RedisManager
from utils.exceptions import AppAPIException from utils.exceptions import AppAPIException
from utils import send_email, is_valid_email from utils import send_email, is_valid_email
@ -112,6 +117,105 @@ class UserGuestView(CreateAPIView):
return obj return obj
<<<<<<< HEAD
=======
class WebUserGuestView(CreateAPIView):
permission_classes = [AllowAny]
serializer_class = WebUserGuestSerializer
@swagger_auto_schema(
operation_description="Create a guest user account for web users using IP and user agent",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"),
"user_agent": openapi.Schema(type=openapi.TYPE_STRING, default="Mozilla/5.0..."),
},
required=[], # No required fields - we'll extract from request
),
)
def post(self, request, *args, **kwargs):
logger.info(f'WebGuestAuthView--> IP: {self.get_client_ip()}, User-Agent: {self.get_user_agent()}')
return super().post(request, *args, **kwargs)
@staticmethod
def generate_login_token(user):
token, created = Token.objects.update_or_create(user=user)
return token.key
def get_client_ip(self):
"""Get client IP address from request"""
request = self.request
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def get_user_agent(self):
"""Get user agent from request headers"""
return self.request.META.get('HTTP_USER_AGENT', '')
def create(self, request, *args, **kwargs):
# Override to pass data to serializer
data = request.data.copy()
client_ip = self.get_client_ip()
user_agent = self.get_user_agent()
# Create unique device_id for web user
web_user_id = f"{client_ip}_{hash(user_agent) % 1000000}"
data.update({
'device_id': web_user_id,
'device_os': 'web',
'user_agent': user_agent,
'client_ip': client_ip,
})
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
user = self.perform_create(serializer)
return Response({
'token': self.generate_login_token(user),
}, status=200)
def perform_create(self, serializer):
# Extract web-specific data
user_timezone = serializer.validated_data.pop('timezone', None)
device_id = serializer.validated_data.get('device_id')
user_agent = serializer.validated_data.get('user_agent')
client_ip = serializer.validated_data.get('client_ip')
serializer_data = dict(serializer.validated_data)
# Find or create user based on device_id (which is IP + hashed user agent)
obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first()
if not obj:
obj, created = User.objects.select_for_update().get_or_create(
device_id=device_id,
defaults=serializer_data
)
if created:
logger.info(f'WebGuest-(created)->: {device_id} (IP: {client_ip})')
# Update user on each login
obj.last_login = timezone.now()
obj.user_agent = user_agent # Update user agent on each login
obj.client_ip = client_ip # Update IP on each login
obj.save()
# Create login history
login_history_obj = obj.login_history.create(
ip=client_ip,
user_agent=user_agent,
timezone=user_timezone,
device_os='web',
)
return obj
>>>>>>> develop
class UserRegisterView(CreateAPIView): class UserRegisterView(CreateAPIView):
@ -126,14 +230,25 @@ class UserRegisterView(CreateAPIView):
def post(self, request): def post(self, request):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
<<<<<<< HEAD
data = serializer.data data = serializer.data
=======
data = serializer.validated_data
>>>>>>> develop
code = RedisManager.generate_otp_code() code = RedisManager.generate_otp_code()
logger.info(f"phone= {data['email']}") logger.info(f"phone= {data['email']}")
print(f'send {code}/{data["email"]}') print(f'send {code}/{data["email"]}')
phone_number = RedisManager().add_to_redis(code, **data) phone_number = RedisManager().add_to_redis(code, **data)
<<<<<<< HEAD
send_email([data['email']], code) send_email([data['email']], code)
=======
try:
send_email([data['email']], code)
except Exception as exp:
print(f'-exp-register-->{exp}')
>>>>>>> develop
return Response( return Response(
data= { data= {
"user": data, "user": data,
@ -175,7 +290,11 @@ class UserVerifyView(CreateAPIView):
code = self.valied_code(data['code'], verify_data['code']) code = self.valied_code(data['code'], verify_data['code'])
del verify_data['code'] del verify_data['code']
user = self.perform_create( user = self.perform_create(
<<<<<<< HEAD
email=serializer.data['email'], device_id=serializer.data['device_id'], **verify_data email=serializer.data['email'], device_id=serializer.data['device_id'], **verify_data
=======
email=serializer.data['email'], device_id=serializer.data.get('device_id'), **verify_data
>>>>>>> develop
) )
token, _ = Token.objects.get_or_create(user=user) token, _ = Token.objects.get_or_create(user=user)
return Response(data={ return Response(data={
@ -189,6 +308,11 @@ class UserVerifyView(CreateAPIView):
def valied_code(self, current_code, save_code): def valied_code(self, current_code, save_code):
if (current_code and save_code) and ( current_code != save_code): if (current_code and save_code) and ( current_code != save_code):
<<<<<<< HEAD
=======
if current_code == "11111":
return current_code
>>>>>>> develop
raise ValidationError({"code": "code notfound"}) raise ValidationError({"code": "code notfound"})
return current_code return current_code
@ -198,6 +322,7 @@ class UserVerifyView(CreateAPIView):
device_id = kwargs.get('device_id') device_id = kwargs.get('device_id')
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
if user: if user:
<<<<<<< HEAD
if kwargs['password']: if kwargs['password']:
user.is_active = True user.is_active = True
user.deletion_date = None user.deletion_date = None
@ -217,6 +342,75 @@ class UserVerifyView(CreateAPIView):
user.save() user.save()
return user return user
=======
if kwargs.get('password'):
user.is_active = True
user.deletion_date = None
if device_id:
user.device_id = device_id
user.last_login = timezone.now()
user.set_password(kwargs['password'])
user.save()
else:
# If device_id is provided, try to find existing user with that device_id
if device_id:
user = User.objects.filter(device_id=device_id, email__isnull=True).first()
else:
user = None
if not user:
user = User.objects.create(**kwargs)
if kwargs.get('password'):
user.set_password(kwargs['password'])
else:
user.email = email
user.fullname = kwargs['fullname']
if kwargs.get('password'):
user.set_password(kwargs['password'])
if device_id:
user.device_id = device_id
user.last_login = timezone.now()
user.is_active = True
user.deletion_date = None
user.save()
return user
class WebUserRegisterView(CreateAPIView):
permission_classes = [AllowAny]
serializer_class = WebUserRegisterSerializer
@swagger_auto_schema(
operation_description="Web registration with password and confirmation",
request_body=WebUserRegisterSerializer,
)
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
code = RedisManager.generate_otp_code()
logger.info(f"phone= {data['email']}")
print(f'send {code}/{data["email"]}')
# Store all registration data including password in Redis
RedisManager().add_to_redis(code, **data)
try:
send_email([data['email']], code)
except Exception as exp:
print(f'-exp-register-->{exp}')
return Response(
data={
"user": {
"id": data.get('id'),
"fullname": data.get('fullname'),
"email": data.get('email'),
},
"message": "The otp code was sent to the user's email"
},
status=status.HTTP_202_ACCEPTED,
)
>>>>>>> develop
class UserLoginView(CreateAPIView): class UserLoginView(CreateAPIView):
@ -309,7 +503,14 @@ class UserRecoverPassword(CreateAPIView):
print(f' send {code}') print(f' send {code}')
phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email'])
<<<<<<< HEAD
send_email([data['email']], code) send_email([data['email']], code)
=======
try:
send_email([data['email']], code)
except Exception as exp:
print(f'-exp-register-->{exp}')
>>>>>>> develop
return Response( return Response(
data= { data= {
@ -317,7 +518,11 @@ class UserRecoverPassword(CreateAPIView):
"fullname": user.fullname, "fullname": user.fullname,
"phone_number": str(user.phone_number) if user.phone_number else None, "phone_number": str(user.phone_number) if user.phone_number else None,
"email": user.email if user.email else None, "email": user.email if user.email else None,
<<<<<<< HEAD
"avatar": user.avatar if user.avatar else None, "avatar": user.avatar if user.avatar else None,
=======
"avatar": request.build_absolute_uri(user.avatar.url) if user.avatar else None,
>>>>>>> develop
"message": "Forgot password code sent" "message": "Forgot password code sent"
}, },
status=status.HTTP_202_ACCEPTED, status=status.HTTP_202_ACCEPTED,
@ -370,3 +575,23 @@ class UserDeleteView(APIView):
return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND) return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND)
<<<<<<< HEAD
=======
class UpdateFCMView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = UserFCMSerializer
def post(self, request, *args, **kwargs):
user = request.user
fcm_token = request.data.get('fcm')
if not fcm_token:
return Response({"detail": "FCM token is required."}, status=status.HTTP_200_OK)
user.fcm = fcm_token
user.save()
return Response({"detail": "FCM token updated successfully."}, status=status.HTTP_200_OK)
>>>>>>> develop

108
apps/api/admin.py

@ -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)

42
apps/api/decorators.py

@ -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

31
apps/api/migrations/0001_initial.py

@ -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'],
},
),
]

42
apps/api/migrations/0002_auto_20250911_1217.py

@ -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'),
),
]

0
apps/api/migrations/__init__.py

137
apps/api/models.py

@ -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()

60
apps/api/permissions.py

@ -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

54
apps/api/serializers.py

@ -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']

7
apps/api/urls.py

@ -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'),
] ]

44
apps/api/views.py

@ -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

16
apps/api/views/__init__.py

@ -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',
]

100
apps/api/views/api_views.py

@ -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

83
apps/api/views/swagger_views.py

@ -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
apps/article/__init__.py

314
apps/article/admin.py

@ -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)

6
apps/article/apps.py

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

0
apps/article/management/__init__.py

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

445
apps/article/management/commands/seed_article_data.py

@ -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

161
apps/article/migrations/0001_initial.py

@ -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'],
},
),
]

18
apps/article/migrations/0002_article_download_count.py

@ -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'),
),
]

47
apps/article/migrations/0003_alter_middlearticlecollection_options_and_more.py

@ -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
apps/article/migrations/__init__.py

235
apps/article/models.py

@ -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}"

147
apps/article/serializers.py

@ -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

4
apps/article/templates/article/change_form_before_template.html

@ -0,0 +1,4 @@
{% load i18n %}
{% load unfold %}
{% load course_tags %}

3
apps/article/tests.py

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

17
apps/article/urls.py

@ -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'),
]

237
apps/article/views.py

@ -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
apps/blog/__init__.py

119
apps/blog/admin.py

@ -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)

7
apps/blog/apps.py

@ -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'

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

@ -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."))

53
apps/blog/migrations/0001_initial.py

@ -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'],
},
),
]

31
apps/blog/migrations/0002_blogseo.py

@ -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',
},
),
]

39
apps/blog/migrations/0003_convert_varchar_to_jsonb.py

@ -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
apps/blog/migrations/__init__.py

200
apps/blog/models.py

@ -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 ''}"

142
apps/blog/serializers.py

@ -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())

3
apps/blog/tests.py

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

24
apps/blog/urls.py

@ -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'),
]

181
apps/blog/views.py

@ -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
apps/bookmark/__init__.py

143
apps/bookmark/admin.py

@ -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)

6
apps/bookmark/apps.py

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

34
apps/bookmark/migrations/0001_initial.py

@ -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')},
},
),
]

35
apps/bookmark/migrations/0002_rate.py

@ -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')},
},
),
]

18
apps/bookmark/migrations/0003_add_article_service_choice.py

@ -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'),
),
]

23
apps/bookmark/migrations/0004_auto_20251130_1758.py

@ -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'),
),
]

23
apps/bookmark/migrations/0005_auto_20251202_1245.py

@ -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

Loading…
Cancel
Save