diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8107a33 --- /dev/null +++ b/.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 diff --git a/.env.prod b/.env.prod index 034e084..26f17ca 100644 --- a/.env.prod +++ b/.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 @@ -17,10 +17,10 @@ CELERY_BACKEND=redis://imam-javad_redis:6379/0 FLOWER_UNAUTHENTICATED_API=true TIMEZONE="Asia/Tehran" CELERY_TIMEZONE="Asia/Tehran" - - +UNFOLD_STUDIO="1" +PLAUSIBLE_DOMAIN='http://127.0.0.1:8000/' #[captcha] captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2" captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS" - +ONLINE_CLASS_FRONTEND_DOMAIN="imamjavad.newhorizonco.uk" FCM_API_KEY="" diff --git a/.zencoder/rules/repo.md b/.zencoder/rules/repo.md new file mode 100644 index 0000000..03382f3 --- /dev/null +++ b/.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 \ No newline at end of file diff --git a/BUGFIX_REPORT.md b/BUGFIX_REPORT.md new file mode 100644 index 0000000..e08a028 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c400361 --- /dev/null +++ b/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 `. + - [management/](apps/chat/management/) + +### **`templates/`** - HTML Templates +This directory would hold any HTML templates used by the chat application's views for rendering web pages. +- **Purpose**: Stores HTML templates for rendering chat-related user interfaces. +- **Internal Parts**: Contains `.html` files. +- **External Relationships**: Used by Django views to render dynamic content. + - [templates/](apps/chat/templates/) + +## Integration with Project URLs + +The chat application's URLs are likely included in the main project's URL configuration, typically found in [config/urls.py](config/urls.py). This file acts as the central routing mechanism for the entire application, directing requests to the appropriate views within the `chat` app or other applications. +- [urls.py](config/urls.py) + diff --git a/Dockerfile.prod b/Dockerfile.prod index f814ce5..5e9d65d 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -8,7 +8,7 @@ WORKDIR /usr/src/app ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 -# install psycopg2 dependencies +# install psycopg2 dependencies and ffmpeg RUN apk update && apk add --no-cache \ git \ wget \ @@ -29,7 +29,8 @@ RUN apk update && apk add --no-cache \ freetype \ ttf-freefont \ mesa-gl \ - alsa-lib + alsa-lib \ + ffmpeg # Set environment variables for Chrome diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md new file mode 100644 index 0000000..ec3e538 --- /dev/null +++ b/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 diff --git a/PODCAST_REFACTORING_SUMMARY.md b/PODCAST_REFACTORING_SUMMARY.md new file mode 100644 index 0000000..cab79d4 --- /dev/null +++ b/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//', 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//** +- 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 🔄 | diff --git a/PODCAST_SETUP_GUIDE.md b/PODCAST_SETUP_GUIDE.md new file mode 100644 index 0000000..8b0bdff --- /dev/null +++ b/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 ها و توضیحات کپی شده +- ✅ سیستم آماده برای استفاده + +سیستم پادکست شما کاملاً آماده است! 🎉 diff --git a/README_WEBHOOK.md b/README_WEBHOOK.md new file mode 100644 index 0000000..9fc6833 --- /dev/null +++ b/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) diff --git a/VIDEO_REFACTORING_SUMMARY.md b/VIDEO_REFACTORING_SUMMARY.md new file mode 100644 index 0000000..976b4ab --- /dev/null +++ b/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 diff --git a/adjustemnts.md b/adjustemnts.md new file mode 100644 index 0000000..9717968 --- /dev/null +++ b/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 + ] + } + } +] \ No newline at end of file diff --git a/apps/account/admin/__init__.py b/apps/account/admin/__init__.py index 414639d..fc61670 100644 --- a/apps/account/admin/__init__.py +++ b/apps/account/admin/__init__.py @@ -1,4 +1,74 @@ +<<<<<<< HEAD from .user import * from .professor import * -from .student import * \ No newline at end of file +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 diff --git a/apps/account/admin/location.py b/apps/account/admin/location.py new file mode 100644 index 0000000..c1740e3 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/apps/account/admin/notification.py b/apps/account/admin/notification.py new file mode 100644 index 0000000..b17d676 --- /dev/null +++ b/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',] \ No newline at end of file diff --git a/apps/account/admin/professor.py b/apps/account/admin/professor.py index bc1693f..40e515f 100644 --- a/apps/account/admin/professor.py +++ b/apps/account/admin/professor.py @@ -1,5 +1,10 @@ +<<<<<<< HEAD from django.contrib import admin 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.utils.translation import gettext_lazy as _ from rest_framework.authtoken.models import TokenProxy @@ -8,19 +13,46 @@ from ajaxdatatable.admin import AjaxDatatable from django.contrib import admin from apps.account.models import User from django import forms +<<<<<<< HEAD from django.contrib import admin from django.urls import path, reverse from django.shortcuts import render, redirect 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 +<<<<<<< HEAD @admin.register(ProfessorUser) class ProfessorUserAdmin(UserAdmin, AjaxDatatable): list_display = ( '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', readonly_fields = ('date_joined',) @@ -52,10 +84,54 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable): ) def save_model(self, request, obj, form, change): +<<<<<<< HEAD if not change: obj.set_password(form.cleaned_data['password1']) obj.user_type = User.UserType.PROFESSOR 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') def _phone_number(self, obj): diff --git a/apps/account/admin/student.py b/apps/account/admin/student.py index 51c1666..a56b91c 100644 --- a/apps/account/admin/student.py +++ b/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 rest_framework.authtoken.models import TokenProxy from ajaxdatatable.admin import AjaxDatatable +<<<<<<< HEAD +======= +from unfold.admin import TabularInline, StackedInline +>>>>>>> develop from django.contrib import admin from apps.account.models import User @@ -15,6 +19,7 @@ from django.contrib import messages from apps.account.models import StudentUser, User +<<<<<<< HEAD @admin.register(StudentUser) @@ -22,6 +27,13 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable): list_display = ( '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', readonly_fields = ('date_joined',) exclude = ('password', 'user_permissions') @@ -29,8 +41,11 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable): (None, { 'classes': ('wide',), 'fields': ('fullname', 'email', 'phone_number',), +<<<<<<< HEAD # 'description': 'Please provide the student details including full name, email, and phone number.', +======= +>>>>>>> develop }), ('other', { 'classes': ('wide',), @@ -47,25 +62,48 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable): fieldsets = ( (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), (_('Permissions'), { +<<<<<<< HEAD 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups',), +======= + 'fields': ('is_active', 'groups',), +>>>>>>> develop }), (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), ) @admin.display(description='Phone Number') def _phone_number(self, obj): return obj.phone_number +<<<<<<< HEAD def get_queryset(self, request): # محدود کردن نمایش فقط دانش‌آموزان qs = super().get_queryset(request) 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): if not change: obj.set_password(form.cleaned_data['password1']) +<<<<<<< HEAD obj.user_type = User.UserType.STUDENT +======= + obj.add_role('student') +>>>>>>> develop super().save_model(request, obj, form, change) diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py index 7d8740e..d81c5cd 100644 --- a/apps/account/admin/user.py +++ b/apps/account/admin/user.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from django.contrib import admin from django.contrib.auth.forms import UserChangeForm, UsernameField from django.contrib.auth.admin import UserAdmin @@ -101,3 +102,383 @@ class AdminUserAdmin(UserAdmin, AjaxDatatable): return obj.phone_number 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('{}', token.key) + except Exception as e: + return format_html('{}', 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('{}', 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( + """ + + """, + 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( + '' + 'visibility' + '', + 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( + """ + + """, + 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 diff --git a/apps/account/management/commands/assign_professor_slugs.py b/apps/account/management/commands/assign_professor_slugs.py new file mode 100644 index 0000000..4724e03 --- /dev/null +++ b/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).") + ) diff --git a/apps/account/management/commands/migrate_user_roles.py b/apps/account/management/commands/migrate_user_roles.py new file mode 100644 index 0000000..f5c49f7 --- /dev/null +++ b/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}') diff --git a/apps/account/manager.py b/apps/account/manager.py index b037fa5..fac4a9c 100644 --- a/apps/account/manager.py +++ b/apps/account/manager.py @@ -12,25 +12,25 @@ class UserManager(BaseUserManager): def create_user( self, email: str = None, - fullname: str = None, + # fullname: str = None, password: str = None, **extra_fields ): email = UserManager.normalize_email(email) user = self.model( email=email, - fullname=fullname, + # fullname=fullname, **extra_fields ) user.set_password(password) user.save(using=self._db) return user - def create_superuser(self, email, fullname, password): + def create_superuser(self, email, password, **extra_fields): user = self.create_user( email=email, - fullname=fullname, password=password, + **extra_fields ) user.is_admin = True user.is_staff = True @@ -42,6 +42,7 @@ class UserManager(BaseUserManager): def change_user_type(self, user, new_user_type): + """تغییر نوع کاربر - deprecated، از add_role استفاده کنید""" group_name = f"{new_user_type.capitalize()} Group" if user.user_type != new_user_type and not user.groups.filter(name=group_name).exists(): @@ -50,7 +51,17 @@ class UserManager(BaseUserManager): user.groups.add(new_group) user.save() 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 diff --git a/apps/account/middleware/admin_access.py b/apps/account/middleware/admin_access.py new file mode 100644 index 0000000..ff99fec --- /dev/null +++ b/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') diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py index ea3b60c..1c35550 100644 --- a/apps/account/migrations/0001_initial.py +++ b/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 -from django.db import migrations, models import django.db.models.deletion import phonenumber_field.modelfields import utils.validators +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): @@ -12,8 +13,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dj_language', '0002_auto_20220120_1344'), ('auth', '0012_alter_user_first_name_max_length'), + ('dj_language', '0002_auto_20220120_1344'), ] operations = [ @@ -24,27 +25,34 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=128, verbose_name='password')), ('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')), - ('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/')), - ('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')), ('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_os', models.CharField(choices=[('android', 'android'), ('apple', 'apple')], max_length=16, 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_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)), - ('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')), - ('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={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', + 'verbose_name': 'All Users', + 'verbose_name_plural': 'All Users', 'ordering': ('-id',), + 'unique_together': {('email', 'device_id')}, }, ), migrations.CreateModel( @@ -113,4 +121,31 @@ class Migration(migrations.Migration): }, 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')), + ], + ), ] diff --git a/apps/account/migrations/0002_alter_user_phone_number.py b/apps/account/migrations/0002_alter_user_phone_number.py new file mode 100644 index 0000000..5dba363 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/account/migrations/0003_locationhistory.py b/apps/account/migrations/0003_locationhistory.py new file mode 100644 index 0000000..432503b --- /dev/null +++ b/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)), + ], + ), + ] diff --git a/apps/account/migrations/0004_alter_user_avatar.py b/apps/account/migrations/0004_alter_user_avatar.py new file mode 100644 index 0000000..d25d658 --- /dev/null +++ b/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/'), + ), + ] diff --git a/apps/account/migrations/0005_alter_user_unique_together.py b/apps/account/migrations/0005_alter_user_unique_together.py new file mode 100644 index 0000000..cfed362 --- /dev/null +++ b/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',)}, + ), + ] diff --git a/apps/account/migrations/0006_auto_20251006_1101.py b/apps/account/migrations/0006_auto_20251006_1101.py new file mode 100644 index 0000000..c4ae8fc --- /dev/null +++ b/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), + ] diff --git a/apps/account/migrations/0007_user_user_agent.py b/apps/account/migrations/0007_user_user_agent.py new file mode 100644 index 0000000..5e8d0b3 --- /dev/null +++ b/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"), + ), + ] diff --git a/apps/account/migrations/0008_loginhistory_device_os_loginhistory_user_agent.py b/apps/account/migrations/0008_loginhistory_device_os_loginhistory_user_agent.py new file mode 100644 index 0000000..9a70df9 --- /dev/null +++ b/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"), + ), + ] diff --git a/apps/account/migrations/0009_user_client_ip.py b/apps/account/migrations/0009_user_client_ip.py new file mode 100644 index 0000000..5488aed --- /dev/null +++ b/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"), + ), + ] diff --git a/apps/account/migrations/0010_alter_user_device_os.py b/apps/account/migrations/0010_alter_user_device_os.py new file mode 100644 index 0000000..72fa771 --- /dev/null +++ b/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, + ), + ), + ] diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 1c58ece..aaa5ec4 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -1,7 +1,9 @@ import random +import secrets from dj_language.field import LanguageField from django.contrib.auth.models import AbstractUser from django.db import models +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django.utils import timezone from phonenumber_field.modelfields import PhoneNumberField @@ -14,6 +16,7 @@ class User(AbstractUser): class DeviceOs(models.TextChoices): android = 'android', 'android' apple = 'apple', 'apple' + web = 'web', 'web' class UserType(models.TextChoices): 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) 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) 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_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) + 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_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) @@ -57,13 +70,13 @@ class User(AbstractUser): EMAIL_FIELD = "email" - USERNAME_FIELD = "username" + USERNAME_FIELD = "email" REQUIRED_FIELDS = [] def __str__(self): username = self.email or self.fullname or self.device_id - return f"{username}-({self.user_type})" + return f"{username}" def soft_delete(self): self.deleted_at = timezone.now() @@ -72,12 +85,17 @@ class User(AbstractUser): number = str(random.randint(1000000000, 9999999999)) self.phone_number = f'{self.phone_number}:deleted{number}' 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() def save(self, *args, **kwargs): 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}' + + if self.user_type == self.UserType.PROFESSOR: + self._ensure_professor_slug() + return super().save(*args, **kwargs) def get_full_name(self): @@ -96,6 +114,157 @@ class User(AbstractUser): return self.UserType.PROFESSOR else: 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: @@ -103,10 +272,11 @@ class User(AbstractUser): verbose_name = "All Users" verbose_name_plural = "All Users" unique_together = ( - 'email', 'device_id' + 'email', ) + class LoginHistory(models.Model): user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history') lat = models.FloatField(verbose_name=_('lat'), null=True, blank=True) @@ -114,5 +284,20 @@ class LoginHistory(models.Model): 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) 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) + + +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) + diff --git a/apps/account/serializers/__init__.py b/apps/account/serializers/__init__.py index 8c6bf0d..5d28da6 100644 --- a/apps/account/serializers/__init__.py +++ b/apps/account/serializers/__init__.py @@ -1,2 +1,7 @@ from .user import * from .notification import * +<<<<<<< HEAD +======= +from .location_history import * +from .auth import * +>>>>>>> develop diff --git a/apps/account/serializers/auth.py b/apps/account/serializers/auth.py new file mode 100644 index 0000000..43223da --- /dev/null +++ b/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 diff --git a/apps/account/serializers/location_history.py b/apps/account/serializers/location_history.py new file mode 100644 index 0000000..907eed6 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index 95beff0..738e393 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -1,4 +1,8 @@ +<<<<<<< HEAD +======= + +>>>>>>> develop from rest_framework import serializers from rest_framework.authtoken.models import Token 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]) fullname = serializers.CharField(required=False) gender = serializers.ChoiceField( +<<<<<<< HEAD choices=User.GenderChoices.choices, required=False, help_text="Select the user's gender." @@ -23,6 +28,33 @@ class UserProfileSerializer(serializers.ModelSerializer): model = User 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'] +======= + 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): # if User.objects.filter(email=value).exists(): @@ -30,17 +62,36 @@ class UserProfileSerializer(serializers.ModelSerializer): # return value 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(): if value is not None: 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() return instance class UserRegisterSerializer(serializers.ModelSerializer): +<<<<<<< HEAD fcm = serializers.CharField(required=False) 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() class Meta: @@ -49,6 +100,7 @@ class UserRegisterSerializer(serializers.ModelSerializer): extra_kwargs = { 'fullname': {'required': True,}, 'email': {'required': True,}, +<<<<<<< HEAD 'device_id': {'required': True,}, } @@ -56,6 +108,23 @@ class UserRegisterSerializer(serializers.ModelSerializer): if User.objects.filter(email=value).exists(): raise serializers.ValidationError("This email is already registered.") 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() 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): password = serializers.CharField(write_only=True) @@ -82,6 +160,15 @@ class UserLoginSerializer(serializers.Serializer): # data.pop('fcm', None) # data.pop('device_id', None) 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): # password = serializers.CharField(write_only=True) @@ -97,6 +184,7 @@ class UserLoginSerializer(serializers.Serializer): +<<<<<<< HEAD class UserRecoverPasswordSerializer(serializers.ModelSerializer): 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): password = serializers.CharField(write_only=True) @@ -140,3 +253,28 @@ class UserGuestSerializer(serializers.ModelSerializer): 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 diff --git a/apps/account/serializers/user_web.py b/apps/account/serializers/user_web.py new file mode 100644 index 0000000..dae3e3b --- /dev/null +++ b/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 + + diff --git a/apps/account/tasks.py b/apps/account/tasks.py index 40217de..7ce0e3e 100644 --- a/apps/account/tasks.py +++ b/apps/account/tasks.py @@ -1,9 +1,108 @@ import time from config.settings import base as settings - from celery import shared_task import requests 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 def send_otp_code(phone_number, code): diff --git a/apps/account/templates/account/group_help_text.html b/apps/account/templates/account/group_help_text.html new file mode 100644 index 0000000..f077e6d --- /dev/null +++ b/apps/account/templates/account/group_help_text.html @@ -0,0 +1,40 @@ + +{% load unfold i18n %} + +
+ {% trans "Driver before template" %} +
+ +
+ {% 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 %} +
diff --git a/apps/account/templates/account/json_editor_field.html b/apps/account/templates/account/json_editor_field.html new file mode 100644 index 0000000..db0c6cc --- /dev/null +++ b/apps/account/templates/account/json_editor_field.html @@ -0,0 +1,800 @@ +{% load i18n %} +
+ +
+
+ + + + + diff --git a/apps/account/templates/account/user_list_section.html b/apps/account/templates/account/user_list_section.html new file mode 100644 index 0000000..3074961 --- /dev/null +++ b/apps/account/templates/account/user_list_section.html @@ -0,0 +1,33 @@ + +{% load unfold i18n %} + + +
+ {% 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 %} + +
diff --git a/apps/account/tests/test_multiple_roles.py b/apps/account/tests/test_multiple_roles.py new file mode 100644 index 0000000..45993b7 --- /dev/null +++ b/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)) diff --git a/apps/account/urls.py b/apps/account/urls.py index 46ff361..4517911 100644 --- a/apps/account/urls.py +++ b/apps/account/urls.py @@ -11,13 +11,17 @@ urlpatterns = [ # URL for user registration, accepts POST requests for creating new user instances. 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('login/', views.UserLoginView.as_view(), name='user-login'), 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. @@ -28,11 +32,13 @@ urlpatterns = [ path('notif/', views.NotificationListView.as_view(), name='user-notif'), 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. path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), # # delete user account path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), + path('update-fcm/', views.UpdateFCMView.as_view(), name='update-fcm'), ] \ No newline at end of file diff --git a/apps/account/views/__init__.py b/apps/account/views/__init__.py index 1668457..d25e678 100644 --- a/apps/account/views/__init__.py +++ b/apps/account/views/__init__.py @@ -1,3 +1,9 @@ from .user import * from .notification import * +<<<<<<< HEAD +======= +from .location_history import * +from .auth import * + +>>>>>>> develop diff --git a/apps/account/views/auth.py b/apps/account/views/auth.py new file mode 100644 index 0000000..a4bf4e3 --- /dev/null +++ b/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) diff --git a/apps/account/views/location_history.py b/apps/account/views/location_history.py new file mode 100644 index 0000000..e997649 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/apps/account/views/notification.py b/apps/account/views/notification.py index d370773..ba8bac9 100644 --- a/apps/account/views/notification.py +++ b/apps/account/views/notification.py @@ -5,8 +5,13 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from rest_framework.permissions import IsAuthenticated from apps.account.serializers import NotificationSerializer, NotificationSendSerializer +<<<<<<< HEAD from apps.account.models import 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 diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 0ba8cb8..515d56d 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -23,7 +23,12 @@ from rest_framework.exceptions import ValidationError from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException 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,UserFCMSerializer,WebUserGuestSerializer +from apps.account.serializers.user_web import WebUserRegisterSerializer +>>>>>>> develop from utils.redis import RedisManager from utils.exceptions import AppAPIException from utils import send_email, is_valid_email @@ -112,6 +117,105 @@ class UserGuestView(CreateAPIView): 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): @@ -126,14 +230,25 @@ class UserRegisterView(CreateAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) +<<<<<<< HEAD data = serializer.data +======= + data = serializer.validated_data +>>>>>>> develop code = RedisManager.generate_otp_code() logger.info(f"phone= {data['email']}") print(f'send {code}/{data["email"]}') phone_number = RedisManager().add_to_redis(code, **data) +<<<<<<< HEAD send_email([data['email']], code) +======= + try: + send_email([data['email']], code) + except Exception as exp: + print(f'-exp-register-->{exp}') +>>>>>>> develop return Response( data= { "user": data, @@ -175,7 +290,11 @@ class UserVerifyView(CreateAPIView): code = self.valied_code(data['code'], verify_data['code']) del verify_data['code'] 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.get('device_id'), **verify_data +>>>>>>> develop ) token, _ = Token.objects.get_or_create(user=user) return Response(data={ @@ -189,6 +308,11 @@ class UserVerifyView(CreateAPIView): def valied_code(self, 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"}) return current_code @@ -198,6 +322,7 @@ class UserVerifyView(CreateAPIView): device_id = kwargs.get('device_id') user = User.objects.filter(email=email).first() if user: +<<<<<<< HEAD if kwargs['password']: user.is_active = True user.deletion_date = None @@ -217,6 +342,75 @@ class UserVerifyView(CreateAPIView): user.save() 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): @@ -309,7 +503,14 @@ class UserRecoverPassword(CreateAPIView): print(f' send {code}') phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) +<<<<<<< HEAD send_email([data['email']], code) +======= + try: + send_email([data['email']], code) + except Exception as exp: + print(f'-exp-register-->{exp}') +>>>>>>> develop return Response( data= { @@ -317,7 +518,11 @@ class UserRecoverPassword(CreateAPIView): "fullname": user.fullname, "phone_number": str(user.phone_number) if user.phone_number else None, "email": user.email if user.email else None, +<<<<<<< HEAD "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" }, 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) +<<<<<<< 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 diff --git a/apps/api/admin.py b/apps/api/admin.py index 8c38f3f..6b94fbf 100644 --- a/apps/api/admin.py +++ b/apps/api/admin.py @@ -1,3 +1,109 @@ 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( + '
' + '{} × {}' + '
', + 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) diff --git a/apps/api/decorators.py b/apps/api/decorators.py new file mode 100644 index 0000000..917d5f4 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/apps/api/migrations/0001_initial.py b/apps/api/migrations/0001_initial.py new file mode 100644 index 0000000..0b77e11 --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/apps/api/migrations/0002_auto_20250911_1217.py b/apps/api/migrations/0002_auto_20250911_1217.py new file mode 100644 index 0000000..0525771 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/api/migrations/__init__.py b/apps/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/models.py b/apps/api/models.py index 71a8362..af8dfea 100644 --- a/apps/api/models.py +++ b/apps/api/models.py @@ -1,3 +1,138 @@ 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() diff --git a/apps/api/permissions.py b/apps/api/permissions.py new file mode 100644 index 0000000..3540a1e --- /dev/null +++ b/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 \ No newline at end of file diff --git a/apps/api/serializers.py b/apps/api/serializers.py new file mode 100644 index 0000000..cdb4707 --- /dev/null +++ b/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'] diff --git a/apps/api/urls.py b/apps/api/urls.py index d240486..24ac722 100644 --- a/apps/api/urls.py +++ b/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 = [ path('', HomeView.as_view()), path('countries/', CountryView.as_view()), + path('comments/', CommentListAPIView.as_view(), name='comment-list'), + path('app-versions/', AppVersionListAPIView.as_view(), name='appversion-list'), ] diff --git a/apps/api/views.py b/apps/api/views.py index 7ddd4a3..caedbe6 100644 --- a/apps/api/views.py +++ b/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 diff --git a/apps/api/views/__init__.py b/apps/api/views/__init__.py new file mode 100644 index 0000000..3503bcd --- /dev/null +++ b/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', +] diff --git a/apps/api/views/api_views.py b/apps/api/views/api_views.py new file mode 100644 index 0000000..d072585 --- /dev/null +++ b/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) diff --git a/apps/api/views/documentation.py b/apps/api/views/documentation.py new file mode 100644 index 0000000..3bf7280 --- /dev/null +++ b/apps/api/views/documentation.py @@ -0,0 +1,1061 @@ +import json +from django.shortcuts import render +from django.views import View +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator + +@method_decorator(staff_member_required, name='dispatch') +class CustomAPIDocumentationView(View): + """ + Custom API Documentation view with collapsible sidebar navigation + Requires admin login to access + """ + + def get(self, request): + api_structure = self._get_api_structure() + context = { + 'api_structure': api_structure, + 'request': request, + 'title': 'Imam Javad API Documentation', + 'description': 'Comprehensive API documentation with interactive examples for the Imam Javad project', + } + return render(request, 'api/documentation.html', context) + + def _get_api_structure(self): + """ + Define the API structure for the Imam Javad project with all apps and endpoints + """ + return { + 'account': { + 'name': 'Account Management', + 'description': 'User authentication, registration, and profile management', + 'endpoints': [ + { + 'name': 'User Registration', + 'method': 'POST', + 'url': '/api/account/register/', + 'description': 'Register a new user account with email verification', + 'parameters': [ + {'name': 'email', 'type': 'string', 'description': 'User email address', 'required': True}, + {'name': 'password', 'type': 'string', 'description': 'User password', 'required': True}, + {'name': 'password_confirm', 'type': 'string', 'description': 'Password confirmation', 'required': True}, + {'name': 'fullname', 'type': 'string', 'description': 'User full name', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "message": "Registration successful. Please check your email for verification code.", + "user_id": 123, + "email": "user@example.com" + }, indent=2), + 'error': json.dumps({ + "error": "Email already exists", + "details": "A user with this email address already exists." + }, indent=2) + } + }, + { + 'name': 'Email Verification', + 'method': 'POST', + 'url': '/api/account/verify/', + 'description': 'Verify user email with verification code', + 'parameters': [ + {'name': 'email', 'type': 'string', 'description': 'User email address', 'required': True}, + {'name': 'code', 'type': 'string', 'description': 'Verification code from email', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "message": "Email verified successfully", + "token": "abc123def456...", + "user": { + "id": 123, + "email": "user@example.com", + "fullname": "John Doe", + "is_verified": True + } + }, indent=2) + } + }, + { + 'name': 'User Login', + 'method': 'POST', + 'url': '/api/account/login/', + 'description': 'Authenticate user and get access token', + 'parameters': [ + {'name': 'email', 'type': 'string', 'description': 'User email address', 'required': True}, + {'name': 'password', 'type': 'string', 'description': 'User password', 'required': True}, + {'name': 'fcm', 'type': 'string', 'description': 'FCM token for notifications', 'required': False}, + {'name': 'device_id', 'type': 'string', 'description': 'Device identifier', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "token": "abc123def456...", + "user": { + "id": 123, + "email": "user@example.com", + "fullname": "John Doe", + "is_verified": True, + "profile_image": None + } + }, indent=2) + } + }, + { + 'name': 'User Profile', + 'method': 'GET', + 'url': '/api/account/profile/', + 'description': 'Get current user profile information', + 'parameters': [ + {'name': 'Authorization', 'type': 'header', 'description': 'Token ', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "id": 123, + "email": "user@example.com", + "fullname": "John Doe", + "phone": "+989123456789", + "profile_image": "https://example.com/media/profiles/user.jpg", + "is_verified": True, + "date_joined": "2024-01-15T10:30:00Z" + }, indent=2) + } + } + ] + }, + 'courses': { + 'name': 'Course Management', + 'description': 'Educational courses, lessons, and learning progress', + 'endpoints': [ + { + 'name': 'Course Categories', + 'method': 'GET', + 'url': '/api/courses/categories/', + 'description': 'Get list of all course categories', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 5, + "results": [ + { + "id": 1, + "title": "Islamic Studies", + "description": "Courses related to Islamic knowledge", + "image": "https://example.com/media/categories/islamic.jpg", + "courses_count": 12 + }, + { + "id": 2, + "title": "Arabic Language", + "description": "Arabic language learning courses", + "image": "https://example.com/media/categories/arabic.jpg", + "courses_count": 8 + } + ] + }, indent=2) + } + }, + { + 'name': 'Course List', + 'method': 'GET', + 'url': '/api/courses/', + 'description': 'Get paginated list of courses with filtering options', + 'parameters': [ + {'name': 'category', 'type': 'integer', 'description': 'Filter by category ID', 'required': False}, + {'name': 'search', 'type': 'string', 'description': 'Search in course titles', 'required': False}, + {'name': 'page', 'type': 'integer', 'description': 'Page number for pagination', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 25, + "next": "http://example.com/api/courses/?page=2", + "previous": None, + "results": [ + { + "id": 1, + "title": "Introduction to Islamic Jurisprudence", + "slug": "intro-islamic-jurisprudence", + "category": { + "id": 1, + "title": "Islamic Studies" + }, + "professor": { + "id": 1, + "name": "Dr. Ahmad Hassan", + "bio": "Expert in Islamic Law" + }, + "thumbnail": "https://example.com/media/courses/course1.jpg", + "duration": "8 weeks", + "lessons_count": 24, + "participants_count": 156, + "price": "50.00", + "is_free": False + } + ] + }, indent=2) + } + } + ] + }, + 'hadis': { + 'name': 'Hadis Collection', + 'description': 'Islamic hadis texts organized by categories and sects', + 'endpoints': [ + { + 'name': 'Hadis Sects', + 'method': 'GET', + 'url': '/api/hadis/categories/', + 'description': 'Get list of hadis sects grouped by type (Shia/Sunni)', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 4, + "results": { + "shia": [ + { + "id": 1, + "title": "Twelver Shia", + "seo_field": None + } + ], + "sunni": [ + { + "id": 3, + "title": "Hanafi", + "seo_field": None + } + ] + } + }, indent=2) + } + }, + { + 'name': 'Hadis Categories', + 'method': 'GET', + 'url': '/api/hadis/categories//', + 'description': 'Get hadis categories tree structure by sect ID', + 'parameters': [ + {'name': 'sect_id', 'type': 'integer', 'description': 'Hadis sect ID', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 10, + "results": { + "quran": [ + { + "id": 1, + "title": "Quranic Interpretations", + "order": 1, + "children": [ + { + "id": 2, + "title": "Tafsir al-Mizan", + "order": 1, + "hadis_count": 45 + } + ] + } + ], + "hadith": [ + { + "id": 10, + "title": "Prophetic Traditions", + "order": 1, + "children": [] + } + ] + } + }, indent=2) + } + } + ] + }, + 'library': { + 'name': 'Digital Library', + 'description': 'Books, documents, and downloadable resources', + 'endpoints': [ + { + 'name': 'Book Categories', + 'method': 'GET', + 'url': '/api/library/categories/', + 'description': 'Get list of book categories', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 8, + "results": [ + { + "id": 1, + "title": "Islamic Jurisprudence", + "description": "Books on Islamic law and jurisprudence", + "books_count": 45 + } + ] + }, indent=2) + } + }, + { + 'name': 'Book List', + 'method': 'GET', + 'url': '/api/library/books/', + 'description': 'Get paginated list of books with filtering and sorting', + 'parameters': [ + {'name': 'category', 'type': 'string', 'description': 'Filter by category slug(s). Can be a single slug or comma-separated list (e.g., "slug1,slug2")', 'required': False}, + {'name': 'collection_id', 'type': 'integer', 'description': 'Filter by collection ID', 'required': False}, + {'name': 'is_bookmark', 'type': 'boolean', 'description': 'Filter bookmarked books (true/false)', 'required': False}, + {'name': 'search', 'type': 'string', 'description': 'Search in book titles, summary, publisher, or ISBN', 'required': False}, + {'name': 'sort', 'type': 'string', 'description': 'Sort by field. Options: created_at, -created_at, view_count, -view_count, download_count, -download_count, title, -title, pin, -pin, -pin,-created_at', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 120, + "results": [ + { + "id": 1, + "title": "Al-Kafi", + "slug": "al-kafi", + "author": "Muhammad ibn Ya'qub al-Kulayni", + "publisher": "Islamic Publications", + "year_of_publication": "2020", + "isbn": "978-1234567890", + "language": 1, + "main_themes": ["Hadith", "Islamic Jurisprudence", "Shia Islam"], + "notable_works": ["Volume 1: Faith and Disbelief", "Volume 2: Reason and Ignorance"], + "summary": "One of the most important Shia hadith collections", + "thumbnail": "https://example.com/media/books/alkafi.jpg", + "file_type": "pdf", + "book_file": "https://example.com/media/books/alkafi.pdf", + "view_count": 5432, + "download_count": 2456, + "pin": True, + "bookmark": False, + "user_rate": { + "is_rated": True, + "rate": 5 + }, + "average_rate": 4.7, + "created_at": "2024-01-15T10:30:00Z" + } + ] + }, indent=2) + } + } + ] + }, + 'videos': { + 'name': 'Video Playlists', + 'description': 'Educational and religious video playlist collections', + 'endpoints': [ + { + 'name': 'Video Categories', + 'method': 'GET', + 'url': '/api/videos/categories/', + 'description': 'Get list of video categories', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 6, + "results": [ + { + "id": 1, + "title": "Lectures", + "slug": "lectures", + "playlist_count": 23 + } + ] + }, indent=2) + } + }, + { + 'name': 'Pinned Collections', + 'method': 'GET', + 'url': '/api/videos/pinned-collections/', + 'description': 'Get pinned video playlist collections', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 3, + "info": { + "categories_count": 6, + "bookmarks_count": 45 + }, + "results": [ + { + "id": 1, + "title": "Featured Videos", + "slug": "featured-videos", + "summary": "Our best video content", + "thumbnail": "https://example.com/media/collections/thumb1.jpg", + "order": 1, + "created_at": "2024-01-15T10:30:00Z" + } + ] + }, indent=2) + } + }, + { + 'name': 'Video Collections', + 'method': 'GET', + 'url': '/api/videos/collections/', + 'description': 'Get video collections with playlists', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 8, + "results": [ + { + "id": 1, + "title": "Islamic Philosophy Series", + "slug": "islamic-philosophy", + "summary": "Complete series on Islamic philosophy", + "playlists": [ + { + "id": 1, + "title": "Introduction to Islamic Philosophy", + "slug": "intro-islamic-philosophy", + "thumbnail": "https://example.com/media/playlists/thumb1.jpg", + "slogan": "Learn the basics of Islamic thought", + "view_count": 1234, + "total_time_formatted": "02:45:30", + "order": 1, + "created_at": "2024-01-15T10:30:00Z" + } + ] + } + ] + }, indent=2) + } + }, + { + 'name': 'Video Playlist List', + 'method': 'GET', + 'url': '/api/videos/playlists/', + 'description': 'Get paginated list of video playlists', + 'parameters': [ + {'name': 'category', 'type': 'string', 'description': 'Filter by category slug', 'required': False}, + {'name': 'collection', 'type': 'string', 'description': 'Filter by collection slug', 'required': False}, + {'name': 'is_bookmark', 'type': 'boolean', 'description': 'Filter bookmarked playlists', 'required': False}, + {'name': 'search', 'type': 'string', 'description': 'Search in playlist titles', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 45, + "results": [ + { + "id": 1, + "title": "Introduction to Islamic Philosophy", + "slug": "intro-islamic-philosophy", + "thumbnail": "https://example.com/media/playlists/thumb1.jpg", + "slogan": "Learn the basics of Islamic thought", + "view_count": 1234, + "total_time_formatted": "02:45:30", + "order": 1, + "created_at": "2024-01-15T10:30:00Z" + } + ] + }, indent=2) + } + }, + { + 'name': 'Video Playlist Detail', + 'method': 'GET', + 'url': '/api/videos/playlists//', + 'description': 'Get detailed information about a video playlist', + 'parameters': [ + {'name': 'slug', 'type': 'string', 'description': 'Playlist slug', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "id": 1, + "title": "Introduction to Islamic Philosophy", + "slug": "intro-islamic-philosophy", + "thumbnail": "https://example.com/media/playlists/thumb1.jpg", + "slogan": "Learn the basics of Islamic thought", + "description": "A comprehensive introduction to Islamic philosophical concepts", + "view_count": 1234, + "total_time_formatted": "02:45:30", + "order": 1, + "status": True, + "categories": [ + { + "id": 1, + "title": "Philosophy", + "slug": "philosophy", + "playlist_count": 12 + } + ], + "user_rate": { + "is_rated": True, + "rate": 5 + }, + "average_rate": 4.5, + "bookmark": True, + "videos": [ + { + "id": 1, + "title": "Chapter 1: Introduction", + "slug": "chapter-1-intro", + "thumbnail": "https://example.com/media/videos/thumb1.jpg", + "description": "First chapter introduction", + "video_time": "00:45:30", + "view_count": 567, + "created_at": "2024-01-15T10:30:00Z" + } + ], + "created_at": "2024-01-15T10:30:00Z" + }, indent=2) + } + } + ] + }, + 'podcast': { + 'name': 'Podcast Platform', + 'description': 'Audio content organized in playlists', + 'endpoints': [ + { + 'name': 'Podcast Categories', + 'method': 'GET', + 'url': '/api/podcast/categories/', + 'description': 'Get list of podcast categories', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 4, + "results": [ + { + "id": 1, + "title": "Religious Discussions", + "slug": "religious-discussions", + "playlist_count": 23 + } + ] + }, indent=2) + } + }, + { + 'name': 'Pinned Collections', + 'method': 'GET', + 'url': '/api/podcast/pinned-collections/', + 'description': 'Get pinned podcast playlist collections', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 3, + "info": { + "categories_count": 6, + "bookmarks_count": 45 + }, + "results": [ + { + "id": 1, + "title": "Featured Podcasts", + "slug": "featured-podcasts", + "summary": "Our best podcast content", + "thumbnail": "https://example.com/media/collections/thumb1.jpg", + "order": 1, + "created_at": "2024-01-15T10:30:00Z" + } + ] + }, indent=2) + } + }, + { + 'name': 'Podcast Collections', + 'method': 'GET', + 'url': '/api/podcast/collections/', + 'description': 'Get podcast collections with playlists', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 8, + "results": [ + { + "id": 1, + "title": "Islamic Philosophy Audio Series", + "slug": "islamic-philosophy-audio", + "summary": "Complete audio series on Islamic philosophy", + "playlists": [ + { + "id": 1, + "title": "Introduction to Islamic Philosophy - Audio", + "slug": "intro-islamic-philosophy-audio", + "thumbnail": "https://example.com/media/playlists/thumb1.jpg", + "slogan": "Learn the basics of Islamic thought through audio", + "view_count": 1234, + "total_time_formatted": "02:45:30", + "order": 1, + "created_at": "2024-01-15T10:30:00Z" + } + ] + } + ] + }, indent=2) + } + }, + { + 'name': 'Podcast Playlist List', + 'method': 'GET', + 'url': '/api/podcast/playlists/', + 'description': 'Get paginated list of podcast playlists with filtering and sorting', + 'parameters': [ + {'name': 'category', 'type': 'string', 'description': 'Filter by category slug(s). Can be a single slug or comma-separated list (e.g., "slug1,slug2")', 'required': False}, + {'name': 'collection', 'type': 'string', 'description': 'Filter by collection slug', 'required': False}, + {'name': 'is_bookmark', 'type': 'boolean', 'description': 'Filter bookmarked playlists', 'required': False}, + {'name': 'search', 'type': 'string', 'description': 'Search in playlist titles', 'required': False}, + {'name': 'sort', 'type': 'string', 'description': 'Sort by field. Options: created_at, -created_at, view_count, -view_count, title, -title, order, -order', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 45, + "results": [ + { + "id": 1, + "title": "Introduction to Islamic Philosophy - Audio", + "slug": "intro-islamic-philosophy-audio", + "thumbnail": "https://example.com/media/playlists/thumb1.jpg", + "slogan": "Learn the basics of Islamic thought through audio", + "view_count": 1234, + "total_time_formatted": "02:45:30", + "episodes_count": 12, + "order": 1, + "created_at": "2024-01-15T10:30:00Z" + } + ] + }, indent=2) + } + }, + { + 'name': 'Podcast Playlist Detail', + 'method': 'GET', + 'url': '/api/podcast/playlists//', + 'description': 'Get detailed information about a podcast playlist', + 'parameters': [ + {'name': 'slug', 'type': 'string', 'description': 'Playlist slug', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "id": 1, + "title": "Introduction to Islamic Philosophy - Audio", + "slug": "intro-islamic-philosophy-audio", + "thumbnail": "https://example.com/media/playlists/thumb1.jpg", + "slogan": "Learn the basics of Islamic thought through audio", + "description": "A comprehensive audio introduction to Islamic philosophical concepts", + "view_count": 1234, + "total_time_formatted": "02:45:30", + "order": 1, + "status": True, + "categories": [ + { + "id": 1, + "title": "Philosophy", + "slug": "philosophy", + "playlist_count": 12 + } + ], + "user_rate": { + "is_rated": True, + "rate": 5 + }, + "average_rate": 4.5, + "bookmark": True, + "podcasts": [ + { + "id": 1, + "title": "Episode 1: Introduction", + "slug": "episode-1-intro", + "thumbnail": "https://example.com/media/podcasts/thumb1.jpg", + "description": "First episode introduction", + "audio_time": "00:45:30", + "view_count": 567, + "created_at": "2024-01-15T10:30:00Z", + "in_user_playlist": False + } + ], + "created_at": "2024-01-15T10:30:00Z" + }, indent=2) + } + } + ] + }, + 'quiz': { + 'name': 'Quiz System', + 'description': 'Interactive quizzes and assessments', + 'endpoints': [ + { + 'name': 'Quiz Detail', + 'method': 'GET', + 'url': '/api/quiz//', + 'description': 'Get quiz details and questions', + 'parameters': [ + {'name': 'quiz_id', 'type': 'integer', 'description': 'Quiz ID', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "id": 1, + "title": "Islamic History Quiz", + "description": "Test your knowledge of Islamic history", + "each_question_timing": 30, + "questions": [ + { + "id": 1, + "question": "When was the Battle of Badr fought?", + "options": [ + {"id": 1, "text": "624 CE"}, + {"id": 2, "text": "625 CE"}, + {"id": 3, "text": "626 CE"}, + {"id": 4, "text": "627 CE"} + ] + } + ] + }, indent=2) + } + } + ] + }, + 'bookmarks': { + 'name': 'Bookmarks & Ratings', + 'description': 'User bookmarks and content ratings', + 'endpoints': [ + { + 'name': 'Add Bookmark', + 'method': 'POST', + 'url': '/api/bookmarks/add/', + 'description': 'Add content to user bookmarks', + 'parameters': [ + {'name': 'content_type', 'type': 'string', 'description': 'Type of content (course, video, etc.)', 'required': True}, + {'name': 'object_id', 'type': 'integer', 'description': 'ID of the content object', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "message": "Bookmark added successfully", + "bookmark_id": 123 + }, indent=2) + } + } + ] + }, + 'api': { + 'name': 'General API', + 'description': 'General endpoints (health, countries, comments, app versions)', + 'endpoints': [ + { + 'name': 'Health / Token Test', + 'method': 'GET', + 'url': '/api/test/', + 'description': 'Health check; echoes optional BUILD_NUMBER header', + 'parameters': [ + {'name': 'BUILD_NUMBER', 'type': 'header', 'description': 'Client build number', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "token": "ok", + "build_number": "1.0.0(100)" + }, indent=2) + } + }, + { + 'name': 'Countries', + 'method': 'GET', + 'url': '/api/test/countries/', + 'description': 'List of countries with dialing codes and flags', + 'parameters': [], + 'response_examples': { + 'success': json.dumps([ + {"name": "Iran", "dial_code": "+98", "code": "IR"} + ], indent=2) + } + }, + { + 'name': 'Comments', + 'method': 'GET', + 'url': '/api/test/comments/', + 'description': 'List comments ordered by order and created_at', + 'parameters': [], + 'response_examples': { + 'success': json.dumps([ + {"id": 1, "user_fullname": "Ali Reza", "comment_text": "Great app!"} + ], indent=2) + } + }, + { + 'name': 'App Versions', + 'method': 'GET', + 'url': '/api/test/app-versions/', + 'description': 'List all app versions', + 'parameters': [], + 'response_examples': { + 'success': json.dumps([ + { + "id": 3, + "version": "1.2.0", + "apk_file": "https://host/media/app_versions/app-release.apk", + "description": "Bug fixes", + "app_type": "google_play", + "app_store_downloads": 1500, + "google_play_downloads": 23000, + "is_active": True + } + ], indent=2) + } + } + ] + }, + 'blog': { + 'name': 'Blog', + 'description': 'Blog posts listing and details', + 'endpoints': [ + { + 'name': 'Blog List', + 'method': 'GET', + 'url': '/api/blog/list/', + 'description': 'List blogs with optional search and sort_by', + 'parameters': [ + {'name': 'search', 'type': 'string', 'description': 'Search in title, slogan, or summary', 'required': False}, + {'name': 'sort_by', 'type': 'string', 'description': "Sorting: 'latest' or 'most_viewed'", 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 1, + "results": [ + {"id": 1, "title": "First blog", "views_count": 10} + ] + }, indent=2) + } + }, + { + 'name': 'Related Blogs', + 'method': 'GET', + 'url': '/api/blog/related//', + 'description': 'Get up to 10 random related blogs excluding current', + 'parameters': [ + {'name': 'blog_id', 'type': 'integer', 'description': 'Current blog ID', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps([ + {"id": 2, "title": "Another blog"} + ], indent=2) + } + }, + { + 'name': 'Blog Detail by Slug', + 'method': 'GET', + 'url': '/api/blog/detail//', + 'description': 'Get blog details by slug; increments view count', + 'parameters': [ + {'name': 'slug', 'type': 'string', 'description': 'Blog slug', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "id": 1, + "title": "First blog", + "views_count": 11 + }, indent=2) + } + } + ] + }, + 'article': { + 'name': 'Articles', + 'description': 'Articles with structured content including Arabic texts and translations', + 'endpoints': [ + { + 'name': 'Article Categories', + 'method': 'GET', + 'url': '/api/article/categories/', + 'description': 'Get list of all active article categories', + 'parameters': [], + 'response_examples': { + 'success': json.dumps([ + { + "id": 1, + "title": "Категория статей", + "slug": "category-1", + "acticle_count": 12 + } + ], indent=2) + } + }, + { + 'name': 'Pinned Collections', + 'method': 'GET', + 'url': '/api/article/pinned-collections/', + 'description': 'Get pinned article collections', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 3, + "info": { + "categories_count": 4, + "bookmarks_count": 12 + }, + "results": [ + { + "id": 1, + "title": "Избранные статьи", + "slug": "featured-articles", + "summary": "Лучшие статьи", + "thumbnail": "https://example.com/media/collections/thumb1.jpg", + "order": 1, + "created_at": "2025-01-15T10:30:00Z" + } + ] + }, indent=2) + } + }, + { + 'name': 'Article Collections', + 'method': 'GET', + 'url': '/api/article/collections/', + 'description': 'Get article collections with articles', + 'parameters': [], + 'response_examples': { + 'success': json.dumps({ + "count": 5, + "results": [ + { + "id": 1, + "title": "Коллекция статей об имамах", + "slug": "imams-collection", + "summary": "Статьи о жизни имамов", + "articles": [ + { + "id": 1, + "title": "Имам Джавад (мир ему)", + "slug": "imam-javad", + "thumbnail": "https://example.com/media/articles/thumb1.jpg", + "description": "Краткая биография", + "view_count": 234, + "created_at": "2025-01-15T10:30:00Z" + } + ] + } + ] + }, indent=2) + } + }, + { + 'name': 'Article List', + 'method': 'GET', + 'url': '/api/article/list/', + 'description': 'Get paginated list of articles with filtering and sorting', + 'parameters': [ + {'name': 'category', 'type': 'string', 'description': 'Filter by category slug(s). Can be a single slug or comma-separated list (e.g., "slug1,slug2")', 'required': False}, + {'name': 'collection', 'type': 'string', 'description': 'Filter by collection slug', 'required': False}, + {'name': 'is_bookmark', 'type': 'boolean', 'description': 'Filter bookmarked articles', 'required': False}, + {'name': 'search', 'type': 'string', 'description': 'Search in article titles', 'required': False}, + {'name': 'sort', 'type': 'string', 'description': 'Sort by field. Options: created_at, -created_at, view_count, -view_count, title, -title', 'required': False}, + ], + 'response_examples': { + 'success': json.dumps({ + "count": 45, + "results": [ + { + "id": 1, + "title": "Имам Джавад (мир ему)", + "slug": "imam-javad", + "thumbnail": "https://example.com/media/articles/thumb1.jpg", + "description": "Краткая биография девятого имама", + "view_count": 234, + "created_at": "2025-01-15T10:30:00Z", + "categories": [ + { + "id": 1, + "title": "Имамы", + "slug": "imams", + "acticle_count": 12 + }, + { + "id": 2, + "title": "Биография", + "slug": "biography", + "acticle_count": 8 + } + ] + } + ] + }, indent=2) + } + }, + { + 'name': 'Article Detail', + 'method': 'GET', + 'url': '/api/article/detail//', + 'description': 'Get detailed article with structured content including Arabic texts and translations', + 'parameters': [ + {'name': 'slug', 'type': 'string', 'description': 'Article slug', 'required': True}, + ], + 'response_examples': { + 'success': json.dumps({ + "id": 1, + "title": "Имам Джавад (мир ему)", + "slug": "imam-javad", + "thumbnail": "https://example.com/media/articles/thumb1.jpg", + "description": "Краткая биография", + "article_file": "https://example.com/media/articles/document.pdf", + "view_count": 235, + "download_count": 45, + "categories": [ + { + "id": 1, + "title": "Имамы", + "slug": "imams", + "acticle_count": 12 + } + ], + "created_at": "2025-01-15T10:30:00Z", + "user_rate": { + "is_rated": True, + "rate": 5 + }, + "average_rate": { + "average": 4.5, + "count": 23 + }, + "bookmark": True, + "article_content": [ + { + "id": 1, + "title": "Введение", + "content": "Краткое введение о теме", + "priority": 1, + "status": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "parts": [ + { + "id": 1, + "order": 1, + "text_sections": [ + { + "id": 1, + "arabic_text": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ", + "translation": "Во имя Аллаха, Милостивого, Милосердного", + "order": 1 + }, + { + "id": 2, + "arabic_text": "الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ", + "translation": "Хвала Аллаху, Господу миров", + "order": 2 + } + ] + }, + { + "id": 2, + "order": 2, + "text_sections": [ + { + "id": 3, + "arabic_text": "الرَّحْمَٰنِ الرَّحِيمِ", + "translation": "Милостивому, Милосердному", + "order": 1 + } + ] + } + ] + } + ] + }, indent=2) + } + } + ] + } + } diff --git a/apps/api/views/swagger_views.py b/apps/api/views/swagger_views.py new file mode 100644 index 0000000..d76db8c --- /dev/null +++ b/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') diff --git a/apps/article/__init__.py b/apps/article/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/apps/article/admin.py b/apps/article/admin.py new file mode 100755 index 0000000..5450b81 --- /dev/null +++ b/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('📌 Pinned (Top)') + else: + return format_html('📋 Regular (Middle)') + + @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('{}', 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('{}', 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) diff --git a/apps/article/apps.py b/apps/article/apps.py new file mode 100755 index 0000000..1a2757e --- /dev/null +++ b/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' diff --git a/apps/article/management/__init__.py b/apps/article/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/article/management/commands/__init__.py b/apps/article/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/article/management/commands/seed_article_data.py b/apps/article/management/commands/seed_article_data.py new file mode 100644 index 0000000..e0229bf --- /dev/null +++ b/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 diff --git a/apps/article/migrations/0001_initial.py b/apps/article/migrations/0001_initial.py new file mode 100755 index 0000000..46004ac --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/apps/article/migrations/0002_article_download_count.py b/apps/article/migrations/0002_article_download_count.py new file mode 100644 index 0000000..981b0df --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/article/migrations/0003_alter_middlearticlecollection_options_and_more.py b/apps/article/migrations/0003_alter_middlearticlecollection_options_and_more.py new file mode 100644 index 0000000..e99eab8 --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/apps/article/migrations/__init__.py b/apps/article/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/apps/article/models.py b/apps/article/models.py new file mode 100755 index 0000000..28a065b --- /dev/null +++ b/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}" + + + diff --git a/apps/article/serializers.py b/apps/article/serializers.py new file mode 100644 index 0000000..8a50354 --- /dev/null +++ b/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 diff --git a/apps/article/templates/article/change_form_before_template.html b/apps/article/templates/article/change_form_before_template.html new file mode 100755 index 0000000..886c7f1 --- /dev/null +++ b/apps/article/templates/article/change_form_before_template.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% load unfold %} +{% load course_tags %} + diff --git a/apps/article/tests.py b/apps/article/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/apps/article/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/article/urls.py b/apps/article/urls.py new file mode 100755 index 0000000..c8ebfa3 --- /dev/null +++ b/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[\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'), +] \ No newline at end of file diff --git a/apps/article/views.py b/apps/article/views.py new file mode 100755 index 0000000..882a2de --- /dev/null +++ b/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) diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/admin.py b/apps/blog/admin.py new file mode 100644 index 0000000..9850a9c --- /dev/null +++ b/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) \ No newline at end of file diff --git a/apps/blog/apps.py b/apps/blog/apps.py new file mode 100644 index 0000000..743ab47 --- /dev/null +++ b/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' diff --git a/apps/blog/management/commands/seed_blog_data.py b/apps/blog/management/commands/seed_blog_data.py new file mode 100644 index 0000000..d958a0a --- /dev/null +++ b/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.")) \ No newline at end of file diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..a30b226 --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/apps/blog/migrations/0002_blogseo.py b/apps/blog/migrations/0002_blogseo.py new file mode 100644 index 0000000..9426a87 --- /dev/null +++ b/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', + }, + ), + ] diff --git a/apps/blog/migrations/0003_convert_varchar_to_jsonb.py b/apps/blog/migrations/0003_convert_varchar_to_jsonb.py new file mode 100644 index 0000000..75c34d6 --- /dev/null +++ b/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); + """ + ), + ] + diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models.py b/apps/blog/models.py new file mode 100644 index 0000000..ba24d59 --- /dev/null +++ b/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 ''}" \ No newline at end of file diff --git a/apps/blog/serializers.py b/apps/blog/serializers.py new file mode 100644 index 0000000..02ea4ba --- /dev/null +++ b/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()) + + diff --git a/apps/blog/tests.py b/apps/blog/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/blog/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/blog/urls.py b/apps/blog/urls.py new file mode 100644 index 0000000..64de8f3 --- /dev/null +++ b/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//', RelatedBlogsAPIView.as_view(), name='related-blogs'), + + # Blog detail by slug (using regex to support different languages) + re_path(r'^detail/(?P[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$', + BlogDetailBySlugAPIView.as_view(), + name='blog-detail'), +] + + + + + + + diff --git a/apps/blog/views.py b/apps/blog/views.py new file mode 100644 index 0000000..dd004e4 --- /dev/null +++ b/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 + ) \ No newline at end of file diff --git a/apps/bookmark/__init__.py b/apps/bookmark/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/bookmark/admin.py b/apps/bookmark/admin.py new file mode 100644 index 0000000..f335dab --- /dev/null +++ b/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( + '{}', + 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( + '{}', + 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( + '{}', + 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) diff --git a/apps/bookmark/apps.py b/apps/bookmark/apps.py new file mode 100644 index 0000000..419b8fc --- /dev/null +++ b/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' diff --git a/apps/bookmark/migrations/0001_initial.py b/apps/bookmark/migrations/0001_initial.py new file mode 100644 index 0000000..55a1ee1 --- /dev/null +++ b/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')}, + }, + ), + ] diff --git a/apps/bookmark/migrations/0002_rate.py b/apps/bookmark/migrations/0002_rate.py new file mode 100644 index 0000000..b651f5d --- /dev/null +++ b/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')}, + }, + ), + ] diff --git a/apps/bookmark/migrations/0003_add_article_service_choice.py b/apps/bookmark/migrations/0003_add_article_service_choice.py new file mode 100644 index 0000000..a3a1c45 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/bookmark/migrations/0004_auto_20251130_1758.py b/apps/bookmark/migrations/0004_auto_20251130_1758.py new file mode 100644 index 0000000..04cee5b --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/bookmark/migrations/0005_auto_20251202_1245.py b/apps/bookmark/migrations/0005_auto_20251202_1245.py new file mode 100644 index 0000000..43097c5 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/bookmark/migrations/__init__.py b/apps/bookmark/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/bookmark/models/__init__.py b/apps/bookmark/models/__init__.py new file mode 100644 index 0000000..d5d868a --- /dev/null +++ b/apps/bookmark/models/__init__.py @@ -0,0 +1,2 @@ +from .bookmark import * +from .rate import * \ No newline at end of file diff --git a/apps/bookmark/models/bookmark.py b/apps/bookmark/models/bookmark.py new file mode 100644 index 0000000..4c79881 --- /dev/null +++ b/apps/bookmark/models/bookmark.py @@ -0,0 +1,90 @@ + + +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + +class Bookmark(models.Model): + """ + Bookmark model for different services like library, podcast, hadith, and video. + """ + + class ServiceChoices(models.TextChoices): + LIBRARY = 'library', 'Library' + PODCAST = 'podcast', 'Podcast' + PODCAST_PLAYLIST = 'podcast_playlist', 'Podcast Playlist' + HADITH = 'hadith', 'Hadith' + VIDEO = 'video', 'Video' + VIDEO_PLAYLIST = 'video_playlist', 'Video Playlist' + ARTICLE = 'article', 'Article' + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bookmarks', verbose_name='User') + service = models.CharField(max_length=20, choices=ServiceChoices.choices, 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') + + class Meta: + verbose_name = 'Bookmark' + verbose_name_plural = 'Bookmarks' + unique_together = ('user', 'service', 'content_id') + + def __str__(self): + return f"{self.user.username} - {self.get_service_display()} - {self.content_id}" + + @classmethod + def is_bookmarked(cls, user, service, content_id): + """ + Check if a specific content is bookmarked by the user. + + Args: + user: User instance + service: Service name (library, podcast, hadith, video) + content_id: ID of the content + + Returns: + Boolean indicating if the content is bookmarked + """ + return cls.objects.filter( + user=user, + service=service, + content_id=content_id, + status=True + ).exists() + + @classmethod + def validate_content_exists(cls, service, content_id): + """ + Validate if content with the given ID exists in the specified service. + + Args: + service: Service name (library, podcast, hadith, video) + content_id: ID of the content to validate + + Returns: + Boolean indicating if the content exists + """ + if service == cls.ServiceChoices.LIBRARY: + from apps.library.models import Book + return Book.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.PODCAST: + from apps.podcast.models import Podcast + return Podcast.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.PODCAST_PLAYLIST: + from apps.podcast.models import PodcastPlaylist + return PodcastPlaylist.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.HADITH: + from apps.hadith.models import Hadith + return Hadith.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.VIDEO: + from apps.video.models import Video + return Video.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.VIDEO_PLAYLIST: + from apps.video.models import VideoPlaylist + return VideoPlaylist.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.ARTICLE: + from apps.article.models import Article + return Article.objects.filter(id=content_id).exists() + return False diff --git a/apps/bookmark/models/rate.py b/apps/bookmark/models/rate.py new file mode 100644 index 0000000..41a5efa --- /dev/null +++ b/apps/bookmark/models/rate.py @@ -0,0 +1,125 @@ + +from django.db import models +from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator, MaxValueValidator +from django.db.models import Avg + +User = get_user_model() + +class Rate(models.Model): + """ + Rate model for different services like library, podcast, hadith, and video. + Users can rate content from 1 to 5. + """ + + class ServiceChoices(models.TextChoices): + LIBRARY = 'library', 'Library' + PODCAST = 'podcast', 'Podcast' + PODCAST_PLAYLIST = 'podcast_playlist', 'Podcast Playlist' + HADITH = 'hadith', 'Hadith' + VIDEO = 'video', 'Video' + VIDEO_PLAYLIST = 'video_playlist', 'Video Playlist' + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='rates', verbose_name='User') + service = models.CharField(max_length=20, choices=ServiceChoices.choices, verbose_name='Service') + content_id = models.PositiveIntegerField(verbose_name='Content ID') + rate = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), 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') + + class Meta: + verbose_name = 'Rate' + verbose_name_plural = 'Rates' + unique_together = ('user', 'service', 'content_id') + + def __str__(self): + return f"{self.user.username} - {self.get_service_display()} - {self.content_id} - {self.rate}" + + @classmethod + def get_user_rate(cls, user, service, content_id): + """ + Get the rate information for a specific content by the user. + + Args: + user: User instance + service: Service name (library, podcast, hadith, video) + content_id: ID of the content + + Returns: + Dictionary containing: + - is_rated: Boolean indicating if the content is rated by the user + - rate: The rate value given by the user (1-5) or None if not rated + """ + try: + rate_obj = cls.objects.get( + user=user, + service=service, + content_id=content_id, + status=True + ) + return { + 'is_rated': True, + 'rate': rate_obj.rate + } + except cls.DoesNotExist: + return { + 'is_rated': False, + 'rate': None + } + + @classmethod + def validate_content_exists(cls, service, content_id): + """ + Validate if content with the given ID exists in the specified service. + + Args: + service: Service name (library, podcast, hadith, video) + content_id: ID of the content to validate + + Returns: + Boolean indicating if the content exists + """ + if service == cls.ServiceChoices.LIBRARY: + from apps.library.models import Book + return Book.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.PODCAST: + from apps.podcast.models import Podcast + return Podcast.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.PODCAST_PLAYLIST: + from apps.podcast.models import PodcastPlaylist + return PodcastPlaylist.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.HADITH: + from apps.hadith.models import Hadith + return Hadith.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.VIDEO: + from apps.video.models import Video + return Video.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.VIDEO_PLAYLIST: + from apps.video.models import VideoPlaylist + return VideoPlaylist.objects.filter(id=content_id).exists() + return False + + @classmethod + def get_average_rate(cls, service, content_id): + """ + Get the average rate for a specific content. + + Args: + service: Service name (library, podcast, hadith, video) + content_id: ID of the content + + Returns: + Float representing the average rate (1-5) or None if no rates + """ + result = cls.objects.filter( + service=service, + content_id=content_id, + status=True + ).aggregate(avg_rate=Avg('rate')) + + return result['avg_rate'] + diff --git a/apps/bookmark/serializers/__init__.py b/apps/bookmark/serializers/__init__.py new file mode 100644 index 0000000..d5d868a --- /dev/null +++ b/apps/bookmark/serializers/__init__.py @@ -0,0 +1,2 @@ +from .bookmark import * +from .rate import * \ No newline at end of file diff --git a/apps/bookmark/serializers/bookmark.py b/apps/bookmark/serializers/bookmark.py new file mode 100644 index 0000000..8a80666 --- /dev/null +++ b/apps/bookmark/serializers/bookmark.py @@ -0,0 +1,76 @@ +from rest_framework import serializers +from apps.bookmark.models import Bookmark + + +class BookmarkSerializer(serializers.ModelSerializer): + """ + Serializer for the Bookmark model. + """ + + class Meta: + model = Bookmark + fields = ['id', 'user', 'service', 'content_id', 'status', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'user'] + + def validate(self, data): + """ + Validate that the content_id exists in the specified service. + """ + service = data.get('service') + content_id = data.get('content_id') + + if not Bookmark.validate_content_exists(service, content_id): + raise serializers.ValidationError( + f"Content does not exist in service." + ) + + return data + + +class BookmarkStatusSerializer(serializers.Serializer): + """ + Serializer for bookmark status information. + This can be used as a SerializerMethodField in other serializers. + """ + is_bookmarked = serializers.BooleanField(default=False) + content_id = serializers.IntegerField() + + + bookmark_info = BookmarkSerializer(read_only=True) + + @staticmethod + def get_bookmark_info(obj, user, service): + """ + Get bookmark information for a specific object. + + Args: + obj: The object being serialized + user: The user to check bookmark status for + service: The service name (library, podcast, hadith, video) + + Returns: + Dictionary with is_bookmarked and content_id + """ + if not user or user.is_anonymous: + return { + 'is_bookmarked': False, + 'content_id': getattr(obj, 'id', None) + } + + content_id = getattr(obj, 'id', None) + if content_id is None: + return { + 'is_bookmarked': False, + 'content_id': None + } + + is_bookmarked = Bookmark.is_bookmarked( + user=user, + service=service, + content_id=content_id + ) + + return { + 'is_bookmarked': is_bookmarked, + 'content_id': content_id + } \ No newline at end of file diff --git a/apps/bookmark/serializers/rate.py b/apps/bookmark/serializers/rate.py new file mode 100644 index 0000000..559702b --- /dev/null +++ b/apps/bookmark/serializers/rate.py @@ -0,0 +1,86 @@ +from rest_framework import serializers +from ..models.rate import Rate + +class RateSerializer(serializers.ModelSerializer): + """ + Serializer for the Rate model. + """ + class Meta: + model = Rate + fields = ('id', 'service', 'content_id', 'rate', 'status', 'created_at', 'updated_at') + read_only_fields = ('id', 'created_at', 'updated_at') + + def validate(self, data): + """ + Validate that the content exists in the specified service. + """ + service = data.get('service') + content_id = data.get('content_id') + + if not Rate.validate_content_exists(service, content_id): + raise serializers.ValidationError(f"Content with ID {content_id} does not exist in {service} service.") + + return data + + def create(self, validated_data): + """ + Create or update a rate. + If a rate already exists for the user, service, and content_id, update it. + """ + user = self.context['request'].user + service = validated_data.get('service') + content_id = validated_data.get('content_id') + + # Try to get an existing rate + try: + rate_obj = Rate.objects.get( + user=user, + service=service, + content_id=content_id + ) + # Update existing rate + for attr, value in validated_data.items(): + setattr(rate_obj, attr, value) + rate_obj.save() + return rate_obj + except Rate.DoesNotExist: + # Create new rate + return Rate.objects.create(user=user, **validated_data) + +class RateStatusSerializer(serializers.Serializer): + """ + Serializer for checking if a user has rated a content and getting the rate value. + """ + service = serializers.ChoiceField(choices=Rate.ServiceChoices.choices) + content_id = serializers.IntegerField(min_value=1) + + def validate(self, data): + """ + Validate that the content exists in the specified service. + """ + service = data.get('service') + content_id = data.get('content_id') + + if not Rate.validate_content_exists(service, content_id): + raise serializers.ValidationError(f"Content with ID {content_id} does not exist in {service} service.") + + return data + +class AverageRateSerializer(serializers.Serializer): + """ + Serializer for getting the average rate of a content. + """ + service = serializers.ChoiceField(choices=Rate.ServiceChoices.choices) + content_id = serializers.IntegerField(min_value=1) + + def validate(self, data): + """ + Validate that the content exists in the specified service. + """ + service = data.get('service') + content_id = data.get('content_id') + + if not Rate.validate_content_exists(service, content_id): + raise serializers.ValidationError(f"Content with ID {content_id} does not exist in {service} service.") + + return data \ No newline at end of file diff --git a/apps/bookmark/tests.py b/apps/bookmark/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/bookmark/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/bookmark/urls.py b/apps/bookmark/urls.py new file mode 100644 index 0000000..4ee0b77 --- /dev/null +++ b/apps/bookmark/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from .views import AddBookmarkView, RemoveBookmarkView, BookmarkStatusView, AddRateView, RemoveRateView, RateStatusView, AverageRateView + +app_name = 'bookmark' + +urlpatterns = [ + # Bookmark URLs + path('add/', AddBookmarkView.as_view(), name='add_bookmark'), + path('remove/', RemoveBookmarkView.as_view(), name='remove_bookmark'), + path('status/', BookmarkStatusView.as_view(), name='bookmark_status'), + + # Rate URLs + path('rate/add/', AddRateView.as_view(), name='add_rate'), + path('rate/remove/', RemoveRateView.as_view(), name='remove_rate'), + path('rate/status/', RateStatusView.as_view(), name='rate_status'), + path('rate/average/', AverageRateView.as_view(), name='average_rate'), +] \ No newline at end of file diff --git a/apps/bookmark/views/__init__.py b/apps/bookmark/views/__init__.py new file mode 100644 index 0000000..d5d868a --- /dev/null +++ b/apps/bookmark/views/__init__.py @@ -0,0 +1,2 @@ +from .bookmark import * +from .rate import * \ No newline at end of file diff --git a/apps/bookmark/views/bookmark.py b/apps/bookmark/views/bookmark.py new file mode 100644 index 0000000..592793c --- /dev/null +++ b/apps/bookmark/views/bookmark.py @@ -0,0 +1,166 @@ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.generics import CreateAPIView, DestroyAPIView +from rest_framework.exceptions import ValidationError +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.bookmark.models import Bookmark +from apps.bookmark.serializers import BookmarkSerializer + + +class AddBookmarkView(CreateAPIView): + """ + Add a bookmark for a specific content in a service. + If the bookmark already exists but is inactive, it will be reactivated. + """ + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + + @swagger_auto_schema( + operation_description="Add a bookmark for a specific content in a service. If the bookmark already exists but is inactive, it will be reactivated.", + tags=["Dobodbi - Bookmarks"], + request_body=BookmarkSerializer, + responses={ + 201: "Bookmark created successfully.", + 200: "Bookmark reactivated or already active.", + 400: "Invalid input." + } + ) + def post(self, request, pk, *args, **kwargs): + return super().post(request,*args, **kwargs) + def create(self, request, *args, **kwargs): + service = request.data.get('service') + content_id = request.data.get('content_id') + + if not service or not content_id: + return Response( + {'error': 'Both service and content_id are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if the bookmark already exists + bookmark = Bookmark.objects.filter( + user=request.user, + service=service, + content_id=content_id + ).first() + + if bookmark: + # If bookmark exists but is inactive, reactivate it + if not bookmark.status: + bookmark.status = True + bookmark.save() + serializer = self.get_serializer(bookmark) + return Response(serializer.data, status=status.HTTP_200_OK) + # If bookmark exists and is active, return it + serializer = self.get_serializer(bookmark) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Create a new bookmark + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class RemoveBookmarkView(DestroyAPIView): + """ + Deactivate a bookmark by setting its status to False using content_id and user. + + The request should specify the content_id of the bookmark to be deactivated. + """ + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + + @swagger_auto_schema( + operation_description="Deactivate a bookmark by setting its status to False using content_id and user.", + tags=["Dobodbi - Bookmarks"], + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'service': openapi.Schema(type=openapi.TYPE_STRING, description='The service associated with the bookmark.'), + 'content_id': openapi.Schema(type=openapi.TYPE_STRING, description='The ID of the content to deactivate the bookmark for.') + }, + required=['service', 'content_id'] + ), + responses={ + 204: "Bookmark deactivated successfully.", + 400: "Invalid input.", + 404: "Bookmark not found or already inactive." + } + ) + def delete(self,request,*args, **kwargs): + return super().delete(request,*args, **kwargs) + def get_object(self): + service = self.request.data.get('service') + content_id = self.request.data.get('content_id') + + if not service or not content_id: + raise ValidationError('Both service and content_id are required') + + bookmark = Bookmark.objects.filter( + user=self.request.user, + service=service, + content_id=content_id, + status=True + ).first() + + if not bookmark: + raise ValidationError('Bookmark not found or already inactive') + + return bookmark + + def destroy(self, request, *args, **kwargs): + bookmark = self.get_object() + bookmark.status = False + bookmark.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class BookmarkStatusView(APIView): + """ + Return the count of bookmarks for each service for the current user. + """ + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description="Return the count of bookmarks for each service for the current user.", + tags=["Dobodbi - Bookmarks"], + responses={ + 200: "A list of bookmark counts for each service.", + } + ) + def get(self, request): + # Get all active bookmarks for the current user + user_bookmarks = Bookmark.objects.filter( + user=request.user, + status=True + ) + + # Get the count of bookmarks for each service + service_counts = {} + for service_choice in Bookmark.ServiceChoices.choices: + service_code = service_choice[0] # Get the service code (e.g., 'library') + service_name = service_choice[1] # Get the service display name (e.g., 'Library') + + # Count bookmarks for this service + count = user_bookmarks.filter(service=service_code).count() + + # Add to results + service_counts[service_code] = { + 'service': service_code, + 'service_display': service_name, + 'count': count + } + + # Convert to list for response + result = list(service_counts.values()) + + return Response(result, status=status.HTTP_200_OK) diff --git a/apps/bookmark/views/rate.py b/apps/bookmark/views/rate.py new file mode 100644 index 0000000..2e691b2 --- /dev/null +++ b/apps/bookmark/views/rate.py @@ -0,0 +1,129 @@ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from apps.bookmark.models import Rate +from apps.bookmark.serializers import RateSerializer, RateStatusSerializer, AverageRateSerializer +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema + +class AddRateView(APIView): + """ + API view for adding or updating a rate. + """ + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description="Add or update a rate for a content.", + tags=["Dobodbi - Bookmarks"], + request_body=RateSerializer, + responses={ + 201: "Rate created successfully.", + 400: "Invalid input." + } + ) + def post(self, request): + """ + Add or update a rate for a content. + """ + serializer = RateSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class RemoveRateView(APIView): + """ + API view for removing a rate. + """ + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description="Remove a rate by setting its status to False.", + tags=["Dobodbi - Bookmarks"], + request_body=RateStatusSerializer, + responses={ + 200: "Rate removed successfully.", + 404: "Rate not found.", + 400: "Invalid input." + } + ) + def post(self, request): + """ + Remove a rate by setting its status to False. + """ + serializer = RateStatusSerializer(data=request.data) + if serializer.is_valid(): + service = serializer.validated_data['service'] + content_id = serializer.validated_data['content_id'] + + try: + rate = Rate.objects.get( + user=request.user, + service=service, + content_id=content_id + ) + rate.status = False + rate.save() + return Response({'message': 'Rate removed successfully'}, status=status.HTTP_200_OK) + except Rate.DoesNotExist: + return Response({'message': 'Rate not found'}, status=status.HTTP_404_NOT_FOUND) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class RateStatusView(APIView): + """ + API view for checking if a user has rated a content and getting the rate value. + """ + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description="Check if a user has rated a content and get the rate value.", + tags=["Dobodbi - Bookmarks"], + request_body=RateStatusSerializer, + responses={ + 200: "Rate status information.", + 400: "Invalid input." + } + ) + def post(self, request): + """ + Check if a user has rated a content and get the rate value. + """ + serializer = RateStatusSerializer(data=request.data) + if serializer.is_valid(): + service = serializer.validated_data['service'] + content_id = serializer.validated_data['content_id'] + + rate_info = Rate.get_user_rate(request.user, service, content_id) + return Response(rate_info, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class AverageRateView(APIView): + """ + API view for getting the average rate of a content. + """ + + @swagger_auto_schema( + operation_description="Get the average rate of a content.", + tags=["Dobodbi - Bookmarks"], + request_body=AverageRateSerializer, + responses={ + 200: "Average rate information.", + 400: "Invalid input." + } + ) + def post(self, request): + """ + Get the average rate of a content. + """ + serializer = AverageRateSerializer(data=request.data) + if serializer.is_valid(): + service = serializer.validated_data['service'] + content_id = serializer.validated_data['content_id'] + + avg_rate = Rate.get_average_rate(service, content_id) + return Response({'average_rate': avg_rate}, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/apps/certificate/admin.py b/apps/certificate/admin.py index 8183b52..92943e1 100644 --- a/apps/certificate/admin.py +++ b/apps/certificate/admin.py @@ -1,13 +1,45 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html -from apps.certificate.models import Certificate - +from unfold.admin import ModelAdmin +from unfold.decorators import display +from apps.certificate.models import Certificate +from utils.admin import project_admin_site +from apps.course.admin.professor_base import CertificateBaseAdmin @admin.register(Certificate) -class CertificateAdmin(admin.ModelAdmin): - list_display = ['student', 'course', 'status', 'created_at'] +class CertificateAdmin(CertificateBaseAdmin): + list_display = ['student', 'course', 'certificate_status', 'created_at'] list_filter = ['status', 'created_at'] - search_fields = ['user__username', 'course__title'] + search_fields = ['id', 'student__username', 'student__email', 'course__title'] readonly_fields = ['created_at', 'updated_at'] + autocomplete_fields = ['student',] + fieldsets = ( + (None, { + 'fields': ('student', 'course', 'status', 'certificate_file') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + @display(description=_("Status"), ordering="status") + def certificate_status(self, obj): + status_classes = { + 'pending': 'unfold-badge unfold-badge--warning', + 'approved': 'unfold-badge unfold-badge--success', + 'rejected': 'unfold-badge unfold-badge--danger', + 'issued': 'unfold-badge unfold-badge--info', + } + + status_class = status_classes.get(obj.status.lower(), 'unfold-badge') + return format_html('{}', status_class, obj.get_status_display()) + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset +project_admin_site.register(Certificate, CertificateAdmin) \ No newline at end of file diff --git a/apps/certificate/migrations/0001_initial.py b/apps/certificate/migrations/0001_initial.py index 8ef3e6c..dac1abc 100644 --- a/apps/certificate/migrations/0001_initial.py +++ b/apps/certificate/migrations/0001_initial.py @@ -1,8 +1,16 @@ +<<<<<<< HEAD # Generated by Django 3.2.7 on 2024-12-14 08:35 from django.db import migrations, models import django.db.models.deletion import filer.fields.file +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.file +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -10,9 +18,14 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD ('course', '0005_participant_unread_messages_count'), ('account', '0005_user_city'), ('filer', '0015_auto_20241214_0835'), +======= + ('account', '0001_initial'), + ('course', '0001_initial'), +>>>>>>> develop ] operations = [ @@ -20,7 +33,11 @@ class Migration(migrations.Migration): name='Certificate', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +<<<<<<< HEAD ('status', models.CharField(choices=[('pending', 'در حال بررسی'), ('approved', 'تایید شده'), ('canceled', 'لغو شده')], default='pending', max_length=10)), +======= + ('status', models.CharField(choices=[('pending', 'pending'), ('approved', 'approved'), ('canceled', 'canceled')], default='pending', max_length=10)), +>>>>>>> develop ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('certificate_file', filer.fields.file.FilerFileField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='filer.file', verbose_name='certificate_file')), diff --git a/apps/certificate/migrations/0002_alter_certificate_certificate_file.py b/apps/certificate/migrations/0002_alter_certificate_certificate_file.py new file mode 100644 index 0000000..1471ea1 --- /dev/null +++ b/apps/certificate/migrations/0002_alter_certificate_certificate_file.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificate', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='certificate', + name='certificate_file', + field=models.FileField(blank=True, null=True, upload_to='certificates/', verbose_name='certificate_file'), + ), + ] diff --git a/apps/certificate/models.py b/apps/certificate/models.py index 87ad053..835f4ed 100644 --- a/apps/certificate/models.py +++ b/apps/certificate/models.py @@ -17,9 +17,7 @@ class Certificate(models.Model): student = models.ForeignKey(StudentUser, on_delete=models.CASCADE, related_name='certificates') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_certificates') status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') - certificate_file = FilerFileField( - related_name='+', on_delete=models.PROTECT, null=True, blank=True, verbose_name=_('certificate_file') - ) + certificate_file = models.FileField(upload_to='certificates/', null=True, blank=True, verbose_name=_('certificate_file')) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/certificate/views.py b/apps/certificate/views.py index 4e12f21..7cbbbc3 100644 --- a/apps/certificate/views.py +++ b/apps/certificate/views.py @@ -1,4 +1,6 @@ from rest_framework import generics, permissions +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi from apps.certificate.models import Certificate from apps.certificate.serializers import CertificateRequestSerializer, CertificateSerializer @@ -10,6 +12,18 @@ class CertificateRequestView(generics.CreateAPIView): serializer_class = CertificateRequestSerializer permission_classes = [permissions.IsAuthenticated] + @swagger_auto_schema( + operation_description="Request a certificate for completed course", + tags=["Imam-Javad - Certificate"], + responses={ + 201: openapi.Response( + description="Certificate request created successfully" + ) + } + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + def perform_create(self, serializer): serializer.save(student=self.request.user) @@ -19,6 +33,19 @@ class UserCertificatesListView(generics.ListAPIView): serializer_class = CertificateSerializer permission_classes = [permissions.IsAuthenticated] + @swagger_auto_schema( + operation_description="Get list of user's certificates", + tags=["Imam-Javad - Certificate"], + responses={ + 200: openapi.Response( + description="List of user certificates", + schema=CertificateSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): return Certificate.objects.filter(student=self.request.user).order_by('-created_at') \ No newline at end of file diff --git a/apps/chat/admin.py b/apps/chat/admin.py index 4f88fbe..8efe223 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -1,59 +1,248 @@ from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.db.models import Count +from unfold.admin import ModelAdmin, TabularInline +from unfold.contrib.filters.admin import RangeNumericFilter, RangeDateTimeFilter from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus +from utils.admin import project_admin_site +class ChatMessageInline(TabularInline): + model = ChatMessage + extra = 0 + fields = ('sender', 'content', 'content_type', 'sent_at', 'is_deleted') + readonly_fields = ('sent_at',) + can_delete = False + show_change_link = True + classes = ['collapse'] + verbose_name = _("Message") + verbose_name_plural = _("Messages") -@admin.register(MessageReadStatus) -class MessageReadStatusAdmin(admin.ModelAdmin): +class MessageReadStatusAdmin(ModelAdmin): list_display = ( - 'user', 'message', 'is_read', 'read_at', + 'user', 'message', 'is_read_status', 'read_at', ) + list_filter = ( + ('read_at', RangeDateTimeFilter), + 'is_read', + ) + search_fields = ('user__username', 'user__email', 'message__content') + readonly_fields = ('read_at',) + + def is_read_status(self, obj): + if obj.is_read: + return format_html('Read') + return format_html('Unread') + + is_read_status.short_description = _("Read Status") -@admin.register(RoomMessage) -class RoomMessageAdmin(admin.ModelAdmin): +class RoomMessageAdmin(ModelAdmin): list_display = ( - 'name', 'room_type', 'course', 'initiator', 'recipient', 'created_at', 'unread_messages_count' + 'name', 'room_type_badge', 'course', 'initiator', + 'messages_count', 'view_messages_button' + ) + list_filter = ( + 'room_type', + ('created_at', RangeDateTimeFilter), + ('updated_at', RangeDateTimeFilter), + 'course' ) - list_filter = ('room_type', 'created_at', 'updated_at', 'course') search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username') ordering = ('-created_at',) - readonly_fields = ('created_at', 'updated_at') + readonly_fields = ('created_at', 'updated_at', 'messages_count') + inlines = [ChatMessageInline] + fieldsets = ( - (None, { - 'fields': ('name', 'description', 'room_type') + (_("Room Information"), { + 'fields': ('name', 'description', 'room_type', 'messages_count'), + 'classes': ('grid-col-2',), }), - ('Relations', { - 'fields': ('course', 'initiator', 'recipient') + (_("Relations"), { + 'fields': ('course', 'initiator', 'recipient'), + 'classes': ('grid-col-2',), }), - ('Timestamps', { - 'fields': ('created_at', 'updated_at') + (_("Timestamps"), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('grid-col-2',), }), ) + + def messages_count(self, obj): + count = obj.messages.count() + return format_html('{}', count) + + messages_count.short_description = _("Messages Count") + + def room_type_badge(self, obj): + if obj.room_type == 'group': + return format_html('Group') + return format_html('Private') + + room_type_badge.short_description = _("Room Type") + + def get_queryset(self, request): + queryset = super().get_queryset(request) + queryset = queryset.annotate( + total_messages=Count('messages') + ) + return queryset + + def view_messages_button(self, obj): + from django.urls import reverse + url = f"{reverse('admin:chat_chatmessage_changelist')}?room__id__exact={obj.id}" + + return format_html( + '' + 'chat {}', + url, _("View Messages") + ) + + view_messages_button.short_description = _("Messages") + + +class MessageReadStatusInline(TabularInline): + model = MessageReadStatus + extra = 0 + fields = ('user', 'is_read', 'read_at') + readonly_fields = ('read_at',) + can_delete = False + show_change_link = True + classes = ['collapse'] + verbose_name = _("Read Status") + verbose_name_plural = _("Read Statuses") -@admin.register(ChatMessage) -class ChatMessageAdmin(admin.ModelAdmin): +class ChatMessageAdmin(ModelAdmin): + change_list_template = 'admin/chat/chatmessage/change_list.html' list_display = ( - 'room', 'sender', 'content_type', 'content_size', 'sent_at', 'is_deleted' + 'id', 'room', 'sender', 'content_type_badge', 'content_preview', + 'content_size_display', 'has_attachment', 'sent_at', 'is_deleted_status' + ) + list_filter = ( + 'room', + 'content_type', + 'is_deleted', + ('sent_at', RangeDateTimeFilter), + ('updated_at', RangeDateTimeFilter), + ('content_size', RangeNumericFilter) ) - list_filter = ('content_type', 'is_deleted', 'sent_at', 'updated_at') search_fields = ('room__name', 'sender__username', 'content') ordering = ('-sent_at',) - readonly_fields = ('sent_at', 'updated_at') + readonly_fields = ('sent_at', 'updated_at', 'content_size', 'attachment_preview') + inlines = [MessageReadStatusInline] + fieldsets = ( - (None, { - 'fields': ('room', 'sender', 'content', 'content_type') + (_("Message Information"), { + 'fields': ('room', 'sender', 'content', 'content_type'), + 'classes': ('grid-col-2',), + }), + (_("Attachments"), { + 'fields': ('file_attachment', 'image_attachment', 'attachment_preview'), + 'classes': ('grid-col-2',), }), - ('Additional Info', { - 'fields': ('content_size',) + (_("Additional Info"), { + 'fields': ('content_size',), + 'classes': ('grid-col-1',), }), - ('Status', { - 'fields': ('is_deleted', 'deleted_at') + (_("Status"), { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('grid-col-2',), }), - ('Timestamps', { - 'fields': ('sent_at', 'updated_at') + (_("Timestamps"), { + 'fields': ('sent_at', 'updated_at'), + 'classes': ('grid-col-2',), }), ) + + def content_preview(self, obj): + if obj.content_type == 'text': + preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content + return preview + return f"{obj.content_type} content" + + content_preview.short_description = _("Content Preview") + + def content_type_badge(self, obj): + badges = { + 'text': ('blue', 'Text'), + 'file': ('yellow', 'File'), + 'audio': ('green', 'Audio'), + 'image': ('pink', 'Image'), + } + color, label = badges.get(obj.content_type, ('gray', obj.content_type)) + return format_html( + '{}', + color, color, label + ) + + content_type_badge.short_description = _("Type") + + def is_deleted_status(self, obj): + if obj.is_deleted: + return format_html('Deleted') + return format_html('Active') + + is_deleted_status.short_description = _("Status") + + def content_size_display(self, obj): + if obj.content_size: + # Format size in KB if larger than 1024 bytes + if obj.content_size > 1024: + size_kb = obj.content_size / 1024 + return f"{size_kb:.1f} KB" + return f"{obj.content_size} bytes" + return "-" + + content_size_display.short_description = _("Size") + + def has_attachment(self, obj): + """Show if message has file/image attachment""" + if obj.image_attachment: + return format_html( + '📷 Image' + ) + elif obj.file_attachment: + return format_html( + '📎 File' + ) + elif obj.content and obj.content_type != 'text': + return format_html( + '🔗 Legacy' + ) + return "-" + + has_attachment.short_description = _("Attachment") + + def attachment_preview(self, obj): + """Display attachment preview in detail view""" + if obj.image_attachment: + return format_html( + '
Image:
' + '' + '
Open in new tab
', + obj.image_attachment.url, + obj.image_attachment.url + ) + elif obj.file_attachment: + return format_html( + '', + obj.file_attachment.url + ) + elif obj.content and obj.content_type != 'text': + return format_html( + '
Legacy URL:
{}
', + obj.content + ) + return "-" + + attachment_preview.short_description = _("Attachment Preview") + +# Register models with the custom admin site +project_admin_site.register(RoomMessage, RoomMessageAdmin) +project_admin_site.register(ChatMessage, ChatMessageAdmin) +project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin) diff --git a/apps/chat/management/__init__.py b/apps/chat/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/chat/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/chat/management/commands/README.md b/apps/chat/management/commands/README.md new file mode 100644 index 0000000..c70c27f --- /dev/null +++ b/apps/chat/management/commands/README.md @@ -0,0 +1,62 @@ +# Chat Management Commands + +## clear_chat_data + +این management command برای پاک کردن داده‌های چت طراحی شده است و دو حالت کاری دارد: + +### حالت پیش‌فرض (محافظت از روم‌های کورس) +در این حالت: +- همه پیام‌ها (ChatMessage) حذف می‌شوند +- همه وضعیت‌های خواندن پیام (MessageReadStatus) حذف می‌شوند +- روم‌هایی که مربوط به کورس نیستند (course=null) حذف می‌شوند +- روم‌هایی که مربوط به کورس هستند حفظ می‌شوند اما پیام‌هایشان حذف می‌شود +- تعداد پیام‌های خوانده نشده روم‌های کورس صفر می‌شود + +### حالت حذف کامل +در این حالت همه داده‌های چت شامل روم‌های کورس نیز حذف می‌شوند. + +## استفاده + +### حالت پیش‌فرض (محافظت از روم‌های کورس) +```bash +# با تأیید کاربر +python manage.py clear_chat_data + +# بدون تأیید کاربر +python manage.py clear_chat_data --force +``` + +### حذف کامل همه داده‌ها +```bash +# با تأیید کاربر +python manage.py clear_chat_data --all-rooms + +# بدون تأیید کاربر +python manage.py clear_chat_data --all-rooms --force +``` + +## پارامترها + +- `--force`: اجرای دستور بدون درخواست تأیید از کاربر +- `--all-rooms`: حذف همه روم‌ها شامل روم‌های مربوط به کورس + +## نکات مهم + +1. **ایمنی**: دستور در یک transaction اجرا می‌شود تا در صورت خطا، تغییرات rollback شوند +2. **گزارش‌دهی**: دستور تعداد رکوردهای حذف شده را نمایش می‌دهد +3. **محافظت از داده‌های کورس**: در حالت پیش‌فرض، روم‌های مربوط به کورس حفظ می‌شوند +4. **بازنشانی شمارنده**: تعداد پیام‌های خوانده نشده روم‌های کورس به صفر تنظیم می‌شود + +## مثال خروجی + +``` +Found: + - 150 messages + - 75 read statuses + - 10 total rooms (3 course rooms, 7 non-course rooms) +✓ Deleted 75 MessageReadStatus records +✓ Deleted 150 ChatMessage records +✓ Deleted 7 non-course RoomMessage records +✓ Reset unread_messages_count for 3 course rooms +Chat data clearing completed successfully! +``` diff --git a/apps/chat/management/commands/__init__.py b/apps/chat/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/chat/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/chat/management/commands/clear_chat_data.py b/apps/chat/management/commands/clear_chat_data.py new file mode 100644 index 0000000..2546076 --- /dev/null +++ b/apps/chat/management/commands/clear_chat_data.py @@ -0,0 +1,79 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils.translation import gettext_lazy as _ + +from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus + + +class Command(BaseCommand): + help = 'Clear chat data: all rooms, messages and read statuses, but preserve course-related rooms' + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + dest='force', + help=_('Force deletion without confirmation'), + ) + parser.add_argument( + '--all-rooms', + action='store_true', + dest='all_rooms', + help=_('Delete ALL rooms including course-related rooms'), + ) + + def handle(self, *args, **options): + force = options['force'] + all_rooms = options['all_rooms'] + + if not force: + if all_rooms: + confirm = input(_('This will delete ALL chat data including course rooms. Are you sure? (yes/no): ')) + else: + confirm = input(_('This will delete all messages and read statuses, and non-course rooms. Course rooms will be preserved but their messages will be deleted. Are you sure? (yes/no): ')) + + if confirm.lower() != 'yes': + self.stdout.write(self.style.WARNING(_('Operation cancelled.'))) + return + + try: + with transaction.atomic(): + # Count existing data + total_messages = ChatMessage.objects.count() + total_read_statuses = MessageReadStatus.objects.count() + total_rooms = RoomMessage.objects.count() + course_rooms = RoomMessage.objects.filter(course__isnull=False).count() + non_course_rooms = RoomMessage.objects.filter(course__isnull=True).count() + + self.stdout.write(self.style.WARNING(f'Found:')) + self.stdout.write(f' - {total_messages} messages') + self.stdout.write(f' - {total_read_statuses} read statuses') + self.stdout.write(f' - {total_rooms} total rooms ({course_rooms} course rooms, {non_course_rooms} non-course rooms)') + + # Step 1: Delete all MessageReadStatus records + deleted_read_statuses = MessageReadStatus.objects.all().delete()[0] + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_read_statuses} MessageReadStatus records')) + + # Step 2: Delete all ChatMessage records + deleted_messages = ChatMessage.objects.all().delete()[0] + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_messages} ChatMessage records')) + + # Step 3: Handle rooms based on options + if all_rooms: + # Delete ALL rooms + deleted_rooms = RoomMessage.objects.all().delete()[0] + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_rooms} RoomMessage records (including course rooms)')) + else: + # Delete only non-course rooms (rooms without course relationship) + deleted_non_course_rooms = RoomMessage.objects.filter(course__isnull=True).delete()[0] + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_non_course_rooms} non-course RoomMessage records')) + + # Reset unread_messages_count for course rooms + course_rooms_updated = RoomMessage.objects.filter(course__isnull=False).update(unread_messages_count=0) + self.stdout.write(self.style.SUCCESS(f'✓ Reset unread_messages_count for {course_rooms_updated} course rooms')) + + self.stdout.write(self.style.SUCCESS(_('Chat data clearing completed successfully!'))) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'Error occurred: {str(e)}')) + raise diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py index 5f599ba..ee278cc 100644 --- a/apps/chat/migrations/0001_initial.py +++ b/apps/chat/migrations/0001_initial.py @@ -1,8 +1,16 @@ +<<<<<<< HEAD # Generated by Django 3.2.4 on 2024-11-22 19:13 from django.conf import settings from django.db import migrations, models import django.db.models.deletion +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -10,26 +18,73 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('course', '0004_auto_20241122_1913'), +======= + ('course', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), +>>>>>>> develop ] operations = [ migrations.CreateModel( +<<<<<<< HEAD +======= + name='RoomMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Room Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('room_type', models.CharField(choices=[('group', 'Group'), ('private', 'Private')], default='group', max_length=10, verbose_name='Room Type')), + ('unread_messages_count', models.IntegerField(default=0)), + ('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='room_messages', to='course.course', verbose_name='Course')), + ('initiator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='initiated_rooms', to=settings.AUTH_USER_MODEL, verbose_name='Initiator')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages_received', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ], + ), + migrations.CreateModel( +>>>>>>> develop name='ChatMessage', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('content', models.TextField(verbose_name='Message Content')), ('content_type', models.CharField(choices=[('text', 'Text'), ('file', 'File'), ('audio', 'Audio'), ('image', 'Image')], default='text', max_length=10, verbose_name='Chat Type')), ('content_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='Content Size (bytes)')), +<<<<<<< HEAD ('is_to_professor', models.BooleanField(default=False, verbose_name='Is to Professor')), +======= + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), +>>>>>>> develop ('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='Sent At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), ('is_deleted', models.BooleanField(default=False, verbose_name='Is deleted')), +<<<<<<< HEAD ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='course.course', verbose_name='Course')), ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages_received', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_sent', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), ], ), +======= + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_sent', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.roommessage', verbose_name='Room')), + ], + ), + migrations.CreateModel( + name='MessageReadStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to='chat.chatmessage', verbose_name='Message')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'unique_together': {('user', 'message')}, + }, + ), +>>>>>>> develop ] diff --git a/apps/chat/migrations/0002_chatmessage_metadata.py b/apps/chat/migrations/0002_chatmessage_metadata.py new file mode 100644 index 0000000..1f0c0ee --- /dev/null +++ b/apps/chat/migrations/0002_chatmessage_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-08-06 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='chatmessage', + name='message_metadata', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apps/chat/migrations/0003_add_file_and_image_attachments.py b/apps/chat/migrations/0003_add_file_and_image_attachments.py new file mode 100644 index 0000000..aef8937 --- /dev/null +++ b/apps/chat/migrations/0003_add_file_and_image_attachments.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.4 on 2025-10-23 11:57 + +import apps.chat.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0002_chatmessage_metadata'), + ] + + operations = [ + migrations.AddField( + model_name='chatmessage', + name='file_attachment', + field=models.FileField(blank=True, help_text='For file and audio messages', max_length=500, null=True, upload_to=apps.chat.models.chat_upload_path, verbose_name='File Attachment'), + ), + migrations.AddField( + model_name='chatmessage', + name='image_attachment', + field=models.ImageField(blank=True, help_text='For image messages', max_length=500, null=True, upload_to=apps.chat.models.chat_upload_path, verbose_name='Image Attachment'), + ), + ] diff --git a/apps/chat/models.py b/apps/chat/models.py index d5263dc..253b947 100644 --- a/apps/chat/models.py +++ b/apps/chat/models.py @@ -1,10 +1,20 @@ from django.db import models +from django.utils import timezone from apps.account.models import User, User from apps.course.models import Course +def chat_upload_path(instance, filename): + """ + Generate upload path for chat attachments + Format: chat/room_{room_id}/YYYY/MM/DD/filename + """ + date = timezone.now() + return f'chat/room_{instance.room_id}/{date.year}/{date.month:02d}/{date.day:02d}/{filename}' + + class RoomMessage(models.Model): class RoomTypeChoices(models.TextChoices): @@ -86,13 +96,52 @@ class ChatMessage(models.Model): blank=True, null=True ) + file_attachment = models.FileField( + upload_to=chat_upload_path, + blank=True, + null=True, + max_length=500, + verbose_name="File Attachment", + help_text="For file and audio messages" + ) + image_attachment = models.ImageField( + upload_to=chat_upload_path, + blank=True, + null=True, + max_length=500, + verbose_name="Image Attachment", + help_text="For image messages" + ) is_read = models.BooleanField(default=False, verbose_name="Is Read") - + message_metadata = models.JSONField(blank=True, null=True) sent_at = models.DateTimeField(auto_now_add=True, verbose_name="Sent At") updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated At") deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Deleted At") is_deleted = models.BooleanField(default=False, verbose_name="Is deleted") + @property + def file_url(self): + """ + Get file URL - works for both old and new messages + For backward compatibility with messages using content field + """ + if self.image_attachment: + return self.image_attachment.url + elif self.file_attachment: + return self.file_attachment.url + elif self.content and self.content_type != 'text': + # Legacy messages with URL in content field + return self.content + return None + + def delete(self, *args, **kwargs): + """Override delete to remove uploaded files""" + if self.file_attachment: + self.file_attachment.delete(save=False) + if self.image_attachment: + self.image_attachment.delete(save=False) + super().delete(*args, **kwargs) + def __str__(self): return f"Message from {self.sender} in {self.room}" diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py index e86b7ee..b42b347 100644 --- a/apps/course/admin/__init__.py +++ b/apps/course/admin/__init__.py @@ -1,3 +1,8 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +<<<<<<< HEAD +from .participant import * +======= +from .participant import * +from .live_session import * +>>>>>>> develop diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index 3e6a13e..dbcb794 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -1,8 +1,12 @@ +<<<<<<< HEAD +======= +>>>>>>> develop import os import hashlib from django.contrib import admin +<<<<<<< HEAD from django import forms from ajaxdatatable.admin import AjaxDatatable @@ -28,10 +32,114 @@ class CourseForm(forms.ModelForm): widgets = { 'timing': JsonEditorWidget(attrs={'schema': get_weekly_timing_schema}), 'features': JsonEditorWidget(attrs={'schema': get_course_feature_schema}), +======= +from django.contrib import messages +from django import forms +from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.utils.html import format_html +from django.shortcuts import redirect +from django.urls import reverse_lazy + +from unfold.admin import ModelAdmin, StackedInline, TabularInline +from unfold.decorators import action, display +from unfold.contrib.forms.widgets import WysiwygWidget +from unfold.sections import TableSection +from unfold.contrib.filters.admin import ( + ChoicesDropdownFilter, + MultipleRelatedDropdownFilter, + RangeDateFilter, + RangeNumericFilter, + TextFilter, +) +from unfold.widgets import ( + UnfoldAdminColorInputWidget, + UnfoldAdminRadioSelectWidget, + UnfoldAdminSelectWidget, + UnfoldAdminSplitDateTimeWidget, + UnfoldAdminTextInputWidget, +) +from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlossaryBaseAdmin + +from unfold.contrib.forms.widgets import ArrayWidget +from django.contrib.postgres.fields import ArrayField + +from utils.admin import project_admin_site ,dovoodi_admin_site +from utils.json_editor_field import JsonEditorWidget +from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant, CourseGlossary, CourseAttachment +from apps.course.models.lesson import Lesson, CourseLesson +from apps.account.models import StudentUser, User + +from utils.schema import get_weekly_timing_schema, get_course_feature_schema + + +class CourseTableSection(TableSection): + verbose_name = _("Course Categories") + related_name = "courses" + height = 380 + fields = [ + "title", + "status", + "edit_link" + ] + + def edit_link(self, instance): + from django.utils.html import format_html + + return format_html( + '' + 'visibility' + '', + instance.id + ) + + edit_link.short_description = _("Edit") + + +class CourseCategoryAdmin(ModelAdmin): + list_display = ('name', 'slug', 'course_count') + search_fields = ('name',) + # exclude = ('slug', ) + + list_sections = [CourseTableSection] + fieldsets = ( + (None, { + 'fields': ('name', 'slug') + }), + ) + + @display(description=_("Courses")) + def course_count(self, obj): + count = obj.courses.all().count() + return format_html( + '{}', + count + ) + + + +class CourseForm(forms.ModelForm): + + class Meta: + model = Course + fields = '__all__' + exclude = ('slug',) + + widgets = { + 'timing': JsonEditorWidget(attrs={ + 'schema': get_weekly_timing_schema(), + 'title': _('Course Weekly Schedule'), + }), + 'features': JsonEditorWidget(attrs={ + 'schema': get_course_feature_schema(), + 'title': _('Course Features'), + }), +>>>>>>> develop } help_texts = { 'status': 'If set to inactive, the course will not be displayed.', } +<<<<<<< HEAD # def __init__(self, *args, **kwargs): # super(CourseForm, self).__init__(*args, **kwargs) # # اضافه کردن help_text به فیلد status @@ -68,6 +176,374 @@ class GlossaryAdmin(admin.ModelAdmin): ordering = ('-id',) +======= + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Make short_description required + self.fields['short_description'].required = True + # Make thumbnail required (show required star in add/change forms) + if 'thumbnail' in self.fields: + self.fields['thumbnail'].required = True + + def clean(self): + cleaned_data = super().clean() + thumbnail = cleaned_data.get('thumbnail') + has_existing_thumbnail = bool(getattr(self.instance, 'thumbnail', None)) + + # Disallow clearing the existing thumbnail (must always have a value) + if thumbnail is False: + self.add_error('thumbnail', _('This field is required and cannot be cleared.')) + return cleaned_data + + # On create or when no existing thumbnail, require uploading one + if (thumbnail is None or thumbnail == '') and not has_existing_thumbnail: + self.add_error('thumbnail', _('This field is required.')) + + return cleaned_data + + +class CourseAttachmentInline(StackedInline): + model = CourseAttachment + extra = 0 + fields = ('attachment',) + tab = True + autocomplete_fields = ('attachment',) + + +class CourseGlossaryInline(StackedInline): + model = CourseGlossary + fields = ('glossary',) + extra = 0 + tab = True + show_change_link = True + autocomplete_fields = ('glossary',) + + +class CourseLessonInline(StackedInline): + model = CourseLesson + fields = ('lesson', 'title', 'is_active', 'priority',) + extra = 0 + tab = True + show_change_link = True + ordering_field = "priority" + autocomplete_fields = ('lesson',) + + +class ParticipantAdmin(ModelAdmin): + list_display = ('student_name', 'course_title', 'joined_date',) + list_filter = ( + ('course', MultipleRelatedDropdownFilter), + ) + search_fields = ('student__email', 'student__fullname', 'course__title') + readonly_fields = ('joined_date',) + autocomplete_fields = ('student', 'course') + fieldsets = ( + (None, { + 'fields': ('student', 'course',) + }), + (_('Enrollment Details'), { + 'fields': ('joined_date', 'last_activity', 'progress') + }), + ) + + @display(description=_("Student"), header=True) + def student_name(self, instance: StudentUser): + from django.templatetags.static import static + + # Get avatar image path - use user's avatar if available, otherwise use default + avatar_path = instance.student.avatar.url if instance.student.avatar else static("images/reading(1).png") + + return [ + instance.student.fullname, + None, + None, + { + "path": avatar_path, + "height": 30, + "width": 36, + "borderless": True, + # "squared": True, + }, + ] + + @admin.display(description=_("Course")) + def course_title(self, obj): + if obj.course: + return obj.course.title + return "-" + +class ParticipantInline(TabularInline): + model = Participant + fields = ('student', 'joined_date', ) + readonly_fields = ('joined_date', 'student') + extra = 0 + tab = True + verbose_name = _("Participant") + verbose_name_plural = _("Participants") + show_change_link = True + + autocomplete_fields = ('student',) + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('-joined_date') + def has_add_permission(self, request, obj): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + + + +from django.urls import reverse + +from django import forms +from django.shortcuts import render, redirect +from django.contrib import messages + +from unfold.widgets import UnfoldAdminSelectWidget + +class AddStudentForm(forms.Form): + student = forms.ModelChoiceField( + queryset=User.objects.filter(is_active=True), + label=_("Select Student"), + widget=UnfoldAdminSelectWidget, + required=True + ) + + +class CourseAdmin(DirectCourseAdmin): + form = CourseForm + inlines = [CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, ParticipantInline] + list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online') + list_filter = [ + ('status', ChoicesDropdownFilter), + ('level', ChoicesDropdownFilter), + 'is_online', + 'is_free', + ('category', MultipleRelatedDropdownFilter), + ('price', RangeNumericFilter), + ] + save_as = True + warn_unsaved_form = True + # compressed_fields = True + search_fields = ('id','title', 'description') + exclude = ('slug', ) + readonly_fields = ('final_price',) + autocomplete_fields = ('category', 'professor',) + list_filter_submit = True + change_form_show_cancel_button = True + radio_fields = { + "video_type": admin.HORIZONTAL, + "status": admin.HORIZONTAL, + "level": admin.HORIZONTAL, + } + # formfield_overrides = { + # models.TextField: { + # "widget": WysiwygWidget, + # }, + # } + conditional_fields = { + 'price': "is_free == false", + 'discount_percentage': "is_free == false", + 'final_price': "is_free == false", + 'online_link': "is_online", + 'video_file': "video_type == 'video_file'", + 'video_link': "video_type == 'youtube_link'", + } + + fieldsets = ( + (None, { + 'fields': ('title', 'category', 'professor', 'thumbnail') + }), + (_('Status'), { + 'fields': ('status', 'is_online', 'online_link'), + }), + (_('Course Details'), { + 'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',), + # 'classes': ['tab'], + }), + (_('Media'), { + 'fields': ('video_type', 'video_file', 'video_link'), + }), + (_('Pricing'), { + 'fields': ('is_free', 'price', 'discount_percentage', 'final_price'), + }), + (_('Timing & Features'), { + 'fields': ('timing', 'features'), + # 'classes': ['tab'], + }), + ) + + + @display(description=_("Course"), header=True) + def display_header(self, instance): + from django.templatetags.static import static + + thumbnail_path = instance.thumbnail.url if instance.thumbnail else None + + return [ + instance.title, + # instance.short_description or _("No description"), + None, + None, + { + "path": thumbnail_path, + "height": 40, + "width": 60, + "squared": True, + "borderless": True, + }, + ] + + @display(description=_("Professor")) + def display_professor(self, instance): + return instance.professor.fullname + + @display(description=_("Price")) + def display_price(self, instance): + if instance.is_free: + return format_html('{}', _("Free")) + + if instance.discount_percentage > 0: + return format_html( + '${}' + '${}', + instance.price, + instance.final_price + ) + + return format_html('${}', instance.final_price) + + + actions_row = [ + "view_course_lessons", + "add_student_to_course" + ] + actions_detail = ['add_student_to_course',] + + + @action( + description=_("View Lessons"), + icon="menu_book", + url_path="actions-row-custom-url", + permissions=[ + "is_course_professor", + ], + ) + def view_course_lessons(self, request, object_id): + """Navigate to the list of lessons for this course.""" + course = self.get_object(request, object_id) + if not course: + messages.error(request, _("Course not found")) + return redirect(request.META.get("HTTP_REFERER") or reverse_lazy("admin:course_course_changelist")) + + # Redirect to the lesson list filtered by this course + from django.urls import reverse + url = f"{reverse('admin:course_lesson_changelist')}?course__id__exact={course.id}" + return redirect(url) + + + def has_is_course_professor_permission(self, request, object_id=None): + try: + if request.user.is_staff: + return True + course = self.get_object(request, object_id) + # Check if the current user can manage this course + return course and request.user.can_manage_course(course) + except Exception as e: + print(e) + return False + + + + + @action( + description=_("Add Student to Course"), + icon="person_add", + permissions=[ + "is_course_professor", + ], + ) + def add_student_to_course(self, request, object_id): + """Add a student to this course as a participant.""" + course = self.get_object(request, object_id) + if not course: + messages.error(request, _("Course not found")) + return redirect(reverse("admin:course_course_changelist")) + + if request.method == 'POST': + form = AddStudentForm(request.POST) + if form.is_valid(): + student = form.cleaned_data['student'] + + # Check if the student is already a participant + if Participant.objects.filter(student=student, course=course).exists(): + messages.warning(request, _(f"Student {student.fullname} is already enrolled in this course")) + else: + # اطمینان از اینکه کاربر نقش student دارد + if not student.has_role('student'): + student.add_role('student') + + # Create a new participant + Participant.objects.create( + student=student, + course=course, + ) + messages.success( + request, + _(f"Student {student.fullname} has been successfully added to {course.title}") + ) + + return redirect(reverse("admin:course_course_changelist")) + else: + form = AddStudentForm() + + return render( + request, + "course/add_student_form.html", + { + "form": form, + "object": object, + "title": _("Change detail action for {}").format(object), + **self.admin_site.each_context(request), + }, + ) + + +class GlossaryAdmin(AttachmentGlossaryBaseAdmin): + list_display = ('title', 'description') + search_fields = ('title', 'description') + ordering = ('-id',) + + def is_used_in_professor_courses(self, user, obj): + """آیا این glossary در دوره‌های استاد استفاده شده؟""" + return obj.courseglossary_set.filter(course__professor=user).exists() + + def filter_by_professor_usage(self, user, queryset): + """فیلتر کردن glossary ها بر اساس استفاده در دوره‌های استاد""" + return queryset.filter(courseglossary__course__professor=user).distinct() + + +class CourseGlossaryAdmin(CourseRelatedAdmin): + list_display = ('course', 'glossary_title', 'glossary_description') + list_filter = ('course',) + search_fields = ('glossary__title', 'glossary__description', 'course__title') + ordering = ('-id',) + autocomplete_fields = ('course', 'glossary') + + @admin.display(description=_("Title")) + def glossary_title(self, obj): + return obj.glossary.title + + @admin.display(description=_("Description")) + def glossary_description(self, obj): + return obj.glossary.description +>>>>>>> develop class AttachmentAdminForm(forms.ModelForm): @@ -100,6 +576,7 @@ class AttachmentAdminForm(forms.ModelForm): return f"{base_part}{hash_part}{ext}" # ترکیب بخش اصلی و هش با پسوند return file_name +<<<<<<< HEAD @admin.register(Attachment) @@ -108,10 +585,98 @@ class AttachmentAdmin(admin.ModelAdmin): list_display = ('title', 'course', 'file', 'file_size') list_filter = ('course',) search_fields = ('title', 'file', 'course__title') +======= + + +class AttachmentAdmin(AttachmentGlossaryBaseAdmin): + form = AttachmentAdminForm + list_display = ('title', 'file', 'file_size') + search_fields = ('title', 'file') +>>>>>>> develop def save_model(self, request, obj, form, change): if obj.file: obj.file_size = obj.file.size super().save_model(request, obj, form, change) +<<<<<<< HEAD + +======= + def is_used_in_professor_courses(self, user, obj): + """آیا این attachment در دوره‌های استاد استفاده شده؟""" + return obj.courseattachment_set.filter(course__professor=user).exists() + + def filter_by_professor_usage(self, user, queryset): + """فیلتر کردن attachment ها بر اساس استفاده در دوره‌های استاد""" + return queryset.filter(courseattachment__course__professor=user).distinct() + + +class CourseAttachmentAdmin(CourseRelatedAdmin): + list_display = ('course', 'attachment_title', 'attachment_file', 'attachment_file_size') + list_filter = ('course',) + search_fields = ('attachment__title', 'course__title') + autocomplete_fields = ('course', 'attachment') + + @admin.display(description=_("Title")) + def attachment_title(self, obj): + return obj.attachment.title + + @admin.display(description=_("File")) + def attachment_file(self, obj): + return obj.attachment.file + + @admin.display(description=_("File Size")) + def attachment_file_size(self, obj): + return obj.attachment.file_size + + +from django.contrib import admin as django_admin +try: + django_admin.site.register(Course, CourseAdmin) + django_admin.site.register(CourseCategory, CourseCategoryAdmin) + django_admin.site.register(Glossary, GlossaryAdmin) + django_admin.site.register(Attachment, AttachmentAdmin) +except admin.sites.AlreadyRegistered: + pass + +# ========================================================= +# 2. REGISTER TO PROJECT ADMIN (The Full Admin Panel) +# ========================================================= +project_admin_site.register(Course, CourseAdmin) +project_admin_site.register(CourseCategory, CourseCategoryAdmin) +project_admin_site.register(Glossary, GlossaryAdmin) +project_admin_site.register(CourseGlossary, CourseGlossaryAdmin) +project_admin_site.register(Attachment, AttachmentAdmin) +project_admin_site.register(CourseAttachment, CourseAttachmentAdmin) +project_admin_site.register(Participant, ParticipantAdmin) + + +# ========================================================= +# 3. REGISTER TO DOVOODI ADMIN (The "Ghost" Fix) +# ========================================================= + +# IMPORTANT: Do NOT inherit from CourseAdmin. Inherit from ModelAdmin directly. +# This prevents dragging in 'inlines' and 'autocomplete_fields' that cause errors. + +class HiddenCourseAdmin(ModelAdmin): + # We only need search_fields so the Autocomplete box works + search_fields = ('title', 'id') + + # No inlines + # No autocomplete_fields + # No fieldsets + + # Hide from the Menu + def has_module_permission(self, request): + return False + + # Disable all permissions + def has_add_permission(self, request): + return False + def has_change_permission(self, request, obj=None): + return False + def has_delete_permission(self, request, obj=None): + return False +dovoodi_admin_site.register(Course, HiddenCourseAdmin) +>>>>>>> develop diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py index 14ca87e..2094009 100644 --- a/apps/course/admin/lesson.py +++ b/apps/course/admin/lesson.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from django.contrib import admin from apps.course.models import Lesson, LessonCompletion @@ -22,12 +23,155 @@ class LessonCompletionAdmin(admin.ModelAdmin): list_display = ('student', 'lesson', 'completed_at') search_fields = ('student__fullname', 'student__email', 'lesson__title', 'lesson__course__title') list_filter = ('lesson__course', 'completed_at') +======= +import os +from django.contrib import admin +from django import forms +from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.utils.html import format_html + +from unfold.admin import ModelAdmin +from unfold.decorators import display +from unfold.contrib.forms.widgets import WysiwygWidget +from unfold.contrib.filters.admin import ( + ChoicesDropdownFilter, + MultipleRelatedDropdownFilter, +) +from unfold.widgets import ( + UnfoldAdminRadioSelectWidget, +) + +from utils.admin import project_admin_site +from .professor_base import CourseRelatedAdmin +from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion +from unfold.admin import ModelAdmin, StackedInline, TabularInline + + +class LessonForm(forms.ModelForm): + class Meta: + model = Lesson + fields = '__all__' + widgets = { + 'content_type': UnfoldAdminRadioSelectWidget(), + } + + +class CourseLessonForm(forms.ModelForm): + class Meta: + model = CourseLesson + fields = '__all__' + + +class LessonAdmin(ModelAdmin): + form = LessonForm + list_display = ('title', 'display_duration', 'content_type') + list_filter = ( + ('content_type', ChoicesDropdownFilter), + ) + search_fields = ('title',) + ordering = ('title',) + list_filter_submit = True + radio_fields = { + "content_type": admin.HORIZONTAL, + } + conditional_fields = { + 'content_file': "content_type == 'video_file'", + 'video_link': "content_type == 'youtube_link'", + } + + fieldsets = ( + (None, { + 'fields': ('title', 'duration') + }), + (_('Content'), { + 'fields': ('content_type', 'content_file', 'video_link'), + 'classes': [], + }), + ) + + def get_form(self, request, obj=None, change=False, **kwargs): + form = super().get_form(request, obj, change, **kwargs) + + # Enhanced styling for content_type radio buttons + form.base_fields["content_type"].widget = UnfoldAdminRadioSelectWidget( + choices=Lesson.ContentTypeChoices.choices, + radio_style=admin.HORIZONTAL, + attrs={ + "class": "radio-inline flex gap-4 p-2 rounded-lg bg-gray-50 shadow-sm", + "option_class": "flex items-center p-2 rounded-md hover:bg-white hover:shadow-sm transition-all duration-200", + "label_class": "ml-2 font-medium text-gray-700 cursor-pointer", + "input_class": "form-radio h-5 w-5 text-blue-600 transition duration-150 ease-in-out cursor-pointer", + }, + ) + + return form + + @display(description=_("Duration")) + def display_duration(self, obj): + return format_html( + '{} min', + obj.duration + ) + + +class CourseLessonAdmin(CourseRelatedAdmin): + form = CourseLessonForm + list_display = ('title', 'course', 'display_duration', 'is_active', 'priority') + list_filter = ( + ('course', MultipleRelatedDropdownFilter), + 'is_active', + ) + search_fields = ('title', 'course__title') + ordering = ('course', 'priority') + autocomplete_fields = ('course', 'lesson') + list_filter_submit = True + + fieldsets = ( + (None, { + 'fields': ('course', 'lesson', 'title', 'priority', 'is_active') + }), + ) + + @display(description=_("Duration")) + def display_duration(self, obj): + return format_html( + '{} min', + obj.lesson.duration + ) + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('course', 'priority') + + +class LessonCompletionAdmin(ModelAdmin): + list_display = ('student', 'course_lesson', 'completed_at') + search_fields = ('student__fullname', 'student__email', 'course_lesson__title', 'course_lesson__course__title') + list_filter = ('course_lesson__course', 'completed_at') +>>>>>>> develop ordering = ('-completed_at',) def get_readonly_fields(self, request, obj=None): """ Make fields readonly if the object already exists. """ +<<<<<<< HEAD if obj: return ['student', 'lesson', 'completed_at'] - return [] \ No newline at end of file + return [] +======= + if obj: + return ['student', 'course_lesson', 'completed_at'] + return [] + + +# Register with both admin sites for autocomplete support +from django.contrib import admin as django_admin +django_admin.site.register(Lesson, LessonAdmin) + +# Register with the project admin site +project_admin_site.register(Lesson, LessonAdmin) +project_admin_site.register(CourseLesson, CourseLessonAdmin) +project_admin_site.register(LessonCompletion, LessonCompletionAdmin) +>>>>>>> develop diff --git a/apps/course/admin/live_session.py b/apps/course/admin/live_session.py new file mode 100644 index 0000000..5f2b136 --- /dev/null +++ b/apps/course/admin/live_session.py @@ -0,0 +1,87 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from unfold.admin import ModelAdmin +from unfold.contrib.filters.admin import ( + ChoicesDropdownFilter, + MultipleRelatedDropdownFilter, + RangeDateFilter, +) + +from utils.admin import project_admin_site +from apps.course.models import ( + CourseLiveSession, + LiveSessionRecording, + LiveSessionUser, + USER_ROLE_CHOICES, + RECORDING_TYPE_CHOICES, +) + + +class CourseLiveSessionAdmin(ModelAdmin): + list_display = ("subject", "course", "started_at", "ended_at", "created_at") + list_filter = ( + ("course", MultipleRelatedDropdownFilter), + ("started_at", RangeDateFilter), + ) + search_fields = ("subject", "course__title") + ordering = ("-started_at",) + autocomplete_fields = ("course",) + readonly_fields = ("created_at", "updated_at") + fieldsets = ( + (None, {"fields": ("course", "subject", "started_at", "ended_at")}), + (_("Timestamps"), {"fields": ("created_at", "updated_at")}), + ) + + +class LiveSessionUserAdmin(ModelAdmin): + list_display = ("user", "session", "role", "is_online", "entered_at", "exited_at") + list_filter = ( + ("session", MultipleRelatedDropdownFilter), + ("user", MultipleRelatedDropdownFilter), + ("role", ChoicesDropdownFilter), + ("entered_at", RangeDateFilter), + ("is_online", admin.BooleanFieldListFilter), + ) + search_fields = ( + "user__email", + "user__fullname", + "session__subject", + ) + autocomplete_fields = ("user", "session") + readonly_fields = ("created_at", "updated_at") + fieldsets = ( + (None, {"fields": ("session", "user", "role")}), + (_("Session Timing"), {"fields": ("entered_at", "exited_at", "is_online")}), + (_("Timestamps"), {"fields": ("created_at", "updated_at")}), + ) + + def get_role_choices(self, request): + return USER_ROLE_CHOICES + + +class LiveSessionRecordingAdmin(ModelAdmin): + list_display = ("title", "session", "recording_type", "is_active", "created_at") + list_filter = ( + ("session", MultipleRelatedDropdownFilter), + ("recording_type", ChoicesDropdownFilter), + ("created_at", RangeDateFilter), + ("is_active", admin.BooleanFieldListFilter), + ) + search_fields = ("title", "session__subject", "session__course__title") + autocomplete_fields = ("session",) + readonly_fields = ("created_at", "updated_at") + fieldsets = ( + (None, {"fields": ("session", "title", "recording_type")}), + (_("Files"), {"fields": ("file", "file_time", "thumbnail")}), + (_("Status"), {"fields": ("is_active",)}), + (_("Timestamps"), {"fields": ("created_at", "updated_at")}), + ) + + def get_recording_type_choices(self, request): + return RECORDING_TYPE_CHOICES + + +project_admin_site.register(CourseLiveSession, CourseLiveSessionAdmin) +project_admin_site.register(LiveSessionUser, LiveSessionUserAdmin) +project_admin_site.register(LiveSessionRecording, LiveSessionRecordingAdmin) diff --git a/apps/course/admin/professor_base.py b/apps/course/admin/professor_base.py new file mode 100644 index 0000000..b0b976a --- /dev/null +++ b/apps/course/admin/professor_base.py @@ -0,0 +1,181 @@ +""" +Base admin classes برای استادان +""" +from django.contrib import admin +from django.contrib.admin import ModelAdmin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin as UnfoldModelAdmin + + +class ProfessorBaseAdmin(UnfoldModelAdmin): + """Base admin class برای استادان""" + + def has_module_permission(self, request): + """آیا کاربر می‌تواند این ماژول را ببیند؟""" + # چک کردن احراز هویت + if not request.user.is_authenticated: + return False + + # اولویت اول: staff یا admin + if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'): + return True + # اولویت دوم: professor + return request.user.has_role('professor') + + def has_view_permission(self, request, obj=None): + """آیا می‌تواند مشاهده کند؟""" + # چک کردن احراز هویت + if not request.user.is_authenticated: + return False + + # اولویت اول: staff یا admin - دسترسی کامل + if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'): + return True + # اولویت دوم: professor - دسترسی محدود + if request.user.has_role('professor'): + if obj is None: + return True + return self.can_access_object(request.user, obj) + return False + + def has_add_permission(self, request): + """آیا می‌تواند اضافه کند؟""" + # چک کردن احراز هویت + if not request.user.is_authenticated: + return False + + # اولویت اول: staff یا admin - دسترسی کامل + if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'): + return True + # اولویت دوم: professor + return request.user.has_role('professor') + + def has_change_permission(self, request, obj=None): + """آیا می‌تواند تغییر دهد؟""" + # چک کردن احراز هویت + if not request.user.is_authenticated: + return False + + # اولویت اول: staff یا admin - دسترسی کامل + if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'): + return True + # اولویت دوم: professor - دسترسی محدود + if request.user.has_role('professor'): + if obj is None: + return True + return self.can_access_object(request.user, obj) + return False + + def has_delete_permission(self, request, obj=None): + """آیا می‌تواند حذف کند؟""" + # چک کردن احراز هویت + if not request.user.is_authenticated: + return False + + # اولویت اول: staff یا admin - دسترسی کامل + if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'): + return True + # اولویت دوم: professor - دسترسی محدود + if request.user.has_role('professor'): + if obj is None: + return True + return self.can_access_object(request.user, obj) + return False + + def can_access_object(self, user, obj): + """آیا کاربر می‌تواند به این object دسترسی داشته باشد؟""" + # این method باید در subclass ها override شود + return True + + def get_queryset(self, request): + """فیلتر کردن queryset بر اساس دسترسی کاربر""" + qs = super().get_queryset(request) + + # چک کردن احراز هویت + if not request.user.is_authenticated: + return qs.none() + + # اولویت اول: staff یا admin - دسترسی کامل + if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'): + return qs + # اولویت دوم: professor - دسترسی محدود + if request.user.has_role('professor'): + return self.filter_queryset_for_professor(request, qs) + return qs.none() + + def filter_queryset_for_professor(self, request, queryset): + """فیلتر کردن queryset برای استاد""" + # این method باید در subclass ها override شود + return queryset + + +class CourseRelatedAdmin(ProfessorBaseAdmin): + """Base admin برای مدل‌هایی که به Course مرتبط هستند""" + + def can_access_object(self, user, obj): + """چک کردن دسترسی بر اساس Course""" + course = self.get_course_from_object(obj) + if course: + return user.can_manage_course(course) + return False + + def filter_queryset_for_professor(self, request, queryset): + """فیلتر کردن بر اساس دوره‌های استاد""" + return queryset.filter(course__professor=request.user) + + def get_course_from_object(self, obj): + """دریافت Course از object""" + # این method باید در subclass ها override شود + if hasattr(obj, 'course'): + return obj.course + return None + + +class DirectCourseAdmin(ProfessorBaseAdmin): + """Admin برای خود مدل Course""" + + def can_access_object(self, user, obj): + """چک کردن دسترسی به Course""" + return user.can_manage_course(obj) + + def filter_queryset_for_professor(self, request, queryset): + """فقط دوره‌های خود استاد""" + return queryset.filter(professor=request.user) + + +class AttachmentGlossaryBaseAdmin(ProfessorBaseAdmin): + """Base admin برای Attachment و Glossary""" + + def can_access_object(self, user, obj): + """چک کردن دسترسی - فقط اگر در دوره‌های استاد استفاده شده""" + # چک کنیم که آیا این attachment/glossary در دوره‌های استاد استفاده شده + return self.is_used_in_professor_courses(user, obj) + + def filter_queryset_for_professor(self, request, queryset): + """فیلتر کردن بر اساس استفاده در دوره‌های استاد""" + return self.filter_by_professor_usage(request.user, queryset) + + def is_used_in_professor_courses(self, user, obj): + """آیا در دوره‌های استاد استفاده شده؟""" + # باید در subclass ها پیاده‌سازی شود + return True + + def filter_by_professor_usage(self, user, queryset): + """فیلتر کردن بر اساس استفاده در دوره‌های استاد""" + # باید در subclass ها پیاده‌سازی شود + return queryset + + +class CertificateBaseAdmin(ProfessorBaseAdmin): + """Base admin برای Certificate""" + + def can_access_object(self, user, obj): + """چک کردن دسترسی به Certificate""" + # فقط certificate های دانش‌آموزان دوره‌های خودش + if hasattr(obj, 'course') and obj.course: + return user.can_manage_course(obj.course) + return False + + def filter_queryset_for_professor(self, request, queryset): + """فقط certificate های دانش‌آموزان دوره‌های استاد""" + return queryset.filter(course__professor=request.user) diff --git a/apps/course/management/__init__.py b/apps/course/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/management/commands/__init__.py b/apps/course/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/management/commands/clear_course_data.py b/apps/course/management/commands/clear_course_data.py new file mode 100644 index 0000000..118cb9c --- /dev/null +++ b/apps/course/management/commands/clear_course_data.py @@ -0,0 +1,134 @@ +from django.core.management.base import BaseCommand +from django.db import transaction, connection +from django.db.models import ProtectedError +from django.utils.translation import gettext_lazy as _ + +from apps.course.models import ( + Course, CourseCategory, + Lesson, CourseLesson, LessonCompletion, + Attachment, CourseAttachment, + Glossary, CourseGlossary, + Participant +) + + +class Command(BaseCommand): + help = _('Clear all course-related data from the database') + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + dest='force', + help=_('Force deletion without confirmation'), + ) + parser.add_argument( + '--model', + type=str, + dest='model', + help=_('Specify a single model to clear (e.g., "Course", "Lesson", etc.)'), + ) + parser.add_argument( + '--legacy-only', + action='store_true', + dest='legacy_only', + help=_('Clear only legacy models (before migration to new structure)'), + ) + + def table_exists(self, table_name): + """Check if a table exists in the database.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = %s + ); + """, + [table_name] + ) + return cursor.fetchone()[0] + + def handle(self, *args, **options): + force = options['force'] + specific_model = options.get('model') + legacy_only = options.get('legacy_only') + + if not force and not specific_model: + confirm = input(_('This will delete ALL course-related data. Are you sure? (yes/no): ')) + if confirm.lower() != 'yes': + self.stdout.write(self.style.WARNING(_('Operation cancelled.'))) + return + + # Define all models + all_models = { + 'Course': (Course, 'course_course'), + 'CourseCategory': (CourseCategory, 'course_coursecategory'), + 'Lesson': (Lesson, 'course_lesson'), + 'CourseLesson': (CourseLesson, 'course_courselesson'), + 'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'), + 'Attachment': (Attachment, 'course_attachment'), + 'CourseAttachment': (CourseAttachment, 'course_courseattachment'), + 'Glossary': (Glossary, 'course_glossary'), + 'CourseGlossary': (CourseGlossary, 'course_courseglossary'), + 'Participant': (Participant, 'course_participant'), + } + + # Legacy models (before migration) + legacy_models = { + 'Course': (Course, 'course_course'), + 'CourseCategory': (CourseCategory, 'course_coursecategory'), + 'Lesson': (Lesson, 'course_lesson'), + 'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'), + 'Attachment': (Attachment, 'course_attachment'), + 'Glossary': (Glossary, 'course_glossary'), + 'Participant': (Participant, 'course_participant'), + } + + models_to_use = legacy_models if legacy_only else all_models + + if specific_model: + # Clear only the specified model + if specific_model not in models_to_use: + self.stdout.write(self.style.ERROR(_(f'Unknown model: {specific_model}'))) + self.stdout.write(self.style.WARNING(_(f'Available models: {", ".join(models_to_use.keys())}'))) + return + + model_info = models_to_use[specific_model] + models_to_clear = [(specific_model, model_info[0], model_info[1])] + else: + # Clear all models in the correct order to avoid foreign key constraints + models_to_clear = [] + + # Order matters for foreign key constraints + model_order = [ + 'LessonCompletion', 'CourseLesson', 'Lesson', + 'CourseAttachment', 'Attachment', + 'CourseGlossary', 'Glossary', + 'Participant', 'Course', 'CourseCategory' + ] + + for model_name in model_order: + if model_name in models_to_use: + model_info = models_to_use[model_name] + models_to_clear.append((model_name, model_info[0], model_info[1])) + + # Process each model + for model_name, model_class, table_name in models_to_clear: + # Check if the table exists + if not self.table_exists(table_name): + self.stdout.write(self.style.WARNING(_(f'Table {table_name} does not exist, skipping {model_name}'))) + continue + + try: + count = model_class.objects.count() + model_class.objects.all().delete() + self.stdout.write(self.style.SUCCESS(_(f'Deleted {count} {model_name} records'))) + except ProtectedError as e: + self.stdout.write(self.style.ERROR(_(f'Could not delete {model_name} records due to protected foreign keys'))) + self.stdout.write(self.style.ERROR(str(e))) + except Exception as e: + self.stdout.write(self.style.ERROR(_(f'Error deleting {model_name} records: {str(e)}'))) + + self.stdout.write(self.style.SUCCESS(_('Course data clearing completed'))) \ No newline at end of file diff --git a/apps/course/migrations/0001_initial.py b/apps/course/migrations/0001_initial.py index 1d78597..0098592 100644 --- a/apps/course/migrations/0001_initial.py +++ b/apps/course/migrations/0001_initial.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Generated by Django 3.2.4 on 2024-11-21 20:46 import apps.course.models.course @@ -7,6 +8,17 @@ from django.db import migrations, models import django.db.models.deletion import filer.fields.image import utils.schema +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import apps.course.models.course +import apps.course.models.lesson +import django.db.models.deletion +import filer.fields.image +import utils.schema +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -14,12 +26,27 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD ('account', '0003_auto_20241120_1741'), +======= + ('account', '0001_initial'), +>>>>>>> develop migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), ] operations = [ migrations.CreateModel( +<<<<<<< HEAD +======= + name='CourseCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Category Name')), + ('slug', models.SlugField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( +>>>>>>> develop name='Course', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -29,6 +56,10 @@ class Migration(migrations.Migration): ('video_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.course.course_file_upload_to)), ('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link')), ('is_online', models.BooleanField(default=True, verbose_name='Is Online Course')), +<<<<<<< HEAD +======= + ('online_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Online Class Link')), +>>>>>>> develop ('level', models.CharField(choices=[('beginner', 'Beginner'), ('mid', 'Mid Level'), ('advanced', 'Advanced')], max_length=10, verbose_name='Course Level')), ('duration', models.PositiveIntegerField(verbose_name='Duration (in hours)')), ('lessons_count', models.PositiveIntegerField(verbose_name='Number of Lessons')), @@ -41,6 +72,14 @@ class Migration(migrations.Migration): ('final_price', models.DecimalField(blank=True, decimal_places=2, default=0.0, help_text='This field is automatically calculated based on the discount percentage.', max_digits=10, verbose_name='Course Final Price')), ('timing', models.JSONField(blank=True, default=utils.schema.default_timing, help_text='The Timing information in JSON format.', null=True, verbose_name='Timing')), ('features', models.JSONField(blank=True, default=dict, null=True, verbose_name='Course features')), +<<<<<<< HEAD +======= + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('professor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='account.professoruser')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='course.coursecategory', verbose_name='Category')), +>>>>>>> develop ], options={ 'verbose_name': 'Course', @@ -48,6 +87,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +<<<<<<< HEAD name='CourseCategory', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -98,6 +138,8 @@ class Migration(migrations.Migration): field=filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail'), ), migrations.CreateModel( +======= +>>>>>>> develop name='Attachment', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -112,4 +154,63 @@ class Migration(migrations.Migration): 'ordering': ('-id',), }, ), +<<<<<<< HEAD +======= + migrations.CreateModel( + name='Glossary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=555, verbose_name='Glossary Title')), + ('description', models.TextField(verbose_name='Description')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='glossaries', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Glossary', + 'verbose_name_plural': 'Glossary', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Lesson Title')), + ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (in minutes)')), + ('content_type', models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File'), ('audio_file', 'Audio File')], max_length=50, verbose_name='Content Type')), + ('content_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.lesson.lesson_file_upload_to)), + ('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Link')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.course', verbose_name='Course')), + ], + ), + migrations.CreateModel( + name='LessonCompletion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_at', models.DateTimeField(auto_now_add=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.lesson')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_completions', to='account.studentuser')), + ], + options={ + 'unique_together': {('student', 'lesson')}, + }, + ), + migrations.CreateModel( + name='Participant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('joined_date', models.DateTimeField(auto_now_add=True)), + ('unread_messages_count', models.IntegerField(default=0)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='course.course')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participated_courses', to='account.studentuser')), + ], + options={ + 'unique_together': {('student', 'course')}, + }, + ), +>>>>>>> develop ] diff --git a/apps/course/migrations/0002_alter_course_thumbnail.py b/apps/course/migrations/0002_alter_course_thumbnail.py new file mode 100644 index 0000000..bf66856 --- /dev/null +++ b/apps/course/migrations/0002_alter_course_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='courses/thumbnails/', verbose_name='Thumbnail'), + ), + ] diff --git a/apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py b/apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py new file mode 100644 index 0000000..fb1dc35 --- /dev/null +++ b/apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.8 on 2025-04-04 00:09 + +import utils.schema +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0002_alter_course_thumbnail'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='is_online', + field=models.BooleanField(default=False, verbose_name='Is Online Course'), + ), + migrations.AlterField( + model_name='course', + name='timing', + field=models.JSONField(blank=True, default=utils.schema.default_timing, null=True, verbose_name='Timing'), + ), + migrations.AlterField( + model_name='course', + name='video_link', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AlterField( + model_name='course', + name='video_type', + field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=20, verbose_name='Preview Video Type (YouTube Link or File Upload)'), + ), + ] diff --git a/apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py b/apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py new file mode 100644 index 0000000..8d7a29d --- /dev/null +++ b/apps/course/migrations/0004_alter_attachment_options_alter_glossary_options_and_more.py @@ -0,0 +1,132 @@ +# Generated by Django 5.1.8 on 2025-04-13 01:35 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_alter_user_phone_number'), + ('course', '0003_alter_course_is_online_alter_course_timing_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='attachment', + options={'verbose_name': 'Attachment', 'verbose_name_plural': 'Attachments'}, + ), + migrations.AlterModelOptions( + name='glossary', + options={'verbose_name': 'Glossary', 'verbose_name_plural': 'Glossaries'}, + ), + migrations.RemoveField( + model_name='attachment', + name='course', + ), + migrations.RemoveField( + model_name='glossary', + name='course', + ), + migrations.RemoveField( + model_name='lesson', + name='course', + ), + migrations.RemoveField( + model_name='lesson', + name='is_active', + ), + migrations.RemoveField( + model_name='lesson', + name='priority', + ), + migrations.AddField( + model_name='attachment', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='attachment', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated At'), + ), + migrations.AddField( + model_name='glossary', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='glossary', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated At'), + ), + migrations.AlterField( + model_name='lesson', + name='content_type', + field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=50, verbose_name='Content Type'), + ), + migrations.CreateModel( + name='CourseAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('attachment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_attachments', to='course.attachment', verbose_name='Attachment')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Course Attachment', + 'verbose_name_plural': 'Course Attachments', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='CourseGlossary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='glossaries', to='course.course', verbose_name='Course')), + ('glossary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_glossaries', to='course.glossary', verbose_name='Glossary')), + ], + options={ + 'verbose_name': 'Course Glossary', + 'verbose_name_plural': 'Course Glossaries', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='CourseLesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Lesson Title')), + ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.course', verbose_name='Course')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_lessons', to='course.lesson', verbose_name='Lesson')), + ], + ), + migrations.AlterUniqueTogether( + name='lessoncompletion', + unique_together=set(), + ), + migrations.AddField( + model_name='lessoncompletion', + name='course_lesson', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.courselesson'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='lessoncompletion', + unique_together={('student', 'course_lesson')}, + ), + migrations.RemoveField( + model_name='lessoncompletion', + name='lesson', + ), + ] diff --git a/apps/course/migrations/0005_add_database_indexes.py b/apps/course/migrations/0005_add_database_indexes.py new file mode 100644 index 0000000..9723b80 --- /dev/null +++ b/apps/course/migrations/0005_add_database_indexes.py @@ -0,0 +1,122 @@ +# Generated by Django 5.1.8 on 2025-06-11 19:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_locationhistory'), + ('course', '0004_alter_attachment_options_alter_glossary_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='courselesson', + options={'verbose_name': 'Course Lesson', 'verbose_name_plural': 'Course Lessons'}, + ), + migrations.AlterModelOptions( + name='lesson', + options={'verbose_name': 'Lesson', 'verbose_name_plural': 'Lessons'}, + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['status'], name='course_cour_status_57ffd9_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['is_free'], name='course_cour_is_free_9453a1_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['created_at'], name='course_cour_created_49f06e_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['slug'], name='course_cour_slug_235a66_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['status', 'created_at'], name='course_cour_status_bfcd24_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['category', 'status'], name='course_cour_categor_26bb4d_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['professor', 'status'], name='course_cour_profess_5eae9a_idx'), + ), + migrations.AddIndex( + model_name='courseattachment', + index=models.Index(fields=['course'], name='course_cour_course__106cc8_idx'), + ), + migrations.AddIndex( + model_name='courseattachment', + index=models.Index(fields=['attachment'], name='course_cour_attachm_2da12a_idx'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['course'], name='course_cour_course__4afa4c_idx'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['lesson'], name='course_cour_lesson__e5c835_idx'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['priority'], name='course_cour_priorit_dedac7_idx'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['is_active'], name='course_cour_is_acti_490c61_idx'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['course', 'priority'], name='course_cour_course__192d2c_idx'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['course', 'is_active'], name='course_cour_course__7c6f06_idx'), + ), + migrations.AddIndex( + model_name='lesson', + index=models.Index(fields=['content_type'], name='course_less_content_e1cf57_idx'), + ), + migrations.AddIndex( + model_name='lesson', + index=models.Index(fields=['created_at'], name='course_less_created_4efb58_idx'), + ), + migrations.AddIndex( + model_name='lessoncompletion', + index=models.Index(fields=['student'], name='course_less_student_f3c9b8_idx'), + ), + migrations.AddIndex( + model_name='lessoncompletion', + index=models.Index(fields=['course_lesson'], name='course_less_course__1f3841_idx'), + ), + migrations.AddIndex( + model_name='lessoncompletion', + index=models.Index(fields=['completed_at'], name='course_less_complet_8d2220_idx'), + ), + migrations.AddIndex( + model_name='lessoncompletion', + index=models.Index(fields=['student', 'course_lesson'], name='course_less_student_3b6367_idx'), + ), + migrations.AddIndex( + model_name='participant', + index=models.Index(fields=['student'], name='course_part_student_566b08_idx'), + ), + migrations.AddIndex( + model_name='participant', + index=models.Index(fields=['course'], name='course_part_course__7cbf7c_idx'), + ), + migrations.AddIndex( + model_name='participant', + index=models.Index(fields=['joined_date'], name='course_part_joined__27eaa0_idx'), + ), + migrations.AddIndex( + model_name='participant', + index=models.Index(fields=['student', 'course'], name='course_part_student_c97a97_idx'), + ), + ] diff --git a/apps/course/migrations/0006_participant_is_active.py b/apps/course/migrations/0006_participant_is_active.py new file mode 100644 index 0000000..6c83fd3 --- /dev/null +++ b/apps/course/migrations/0006_participant_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-08-07 14:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0005_add_database_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='is_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/apps/course/migrations/0007_alter_course_thumbnail.py b/apps/course/migrations/0007_alter_course_thumbnail.py new file mode 100644 index 0000000..b8782d3 --- /dev/null +++ b/apps/course/migrations/0007_alter_course_thumbnail.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.4 on 2025-09-16 14:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0006_participant_is_active'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='thumbnail', + field=models.ImageField(default='1', upload_to='courses/thumbnails/', verbose_name='Thumbnail'), + preserve_default=False, + ), + ] diff --git a/apps/course/migrations/0008_auto_20251013_1724.py b/apps/course/migrations/0008_auto_20251013_1724.py new file mode 100644 index 0000000..ae55647 --- /dev/null +++ b/apps/course/migrations/0008_auto_20251013_1724.py @@ -0,0 +1,104 @@ +# Generated by Django 3.2.4 on 2025-10-13 17:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course', '0007_alter_course_thumbnail'), + ] + + operations = [ + migrations.CreateModel( + name='CourseLiveSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(help_text='Topic of the live session.', max_length=255, verbose_name='Subject')), + ('started_at', models.DateTimeField(help_text='Start time of the live session.', verbose_name='Started At')), + ('ended_at', models.DateTimeField(blank=True, help_text='End time of the live session.', null=True, verbose_name='Ended At')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(help_text='Course that this live session belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='live_sessions', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Course Live Session', + 'verbose_name_plural': 'Course Live Sessions', + 'ordering': ('-started_at', '-id'), + }, + ), + migrations.CreateModel( + name='LiveSessionUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('participant', 'Participant'), ('moderator', 'Moderator'), ('observer', 'Observer')], help_text='Role of the user in the session', max_length=50, verbose_name='Role')), + ('entered_at', models.DateTimeField(help_text='Time the user entered the session', verbose_name='Entered At')), + ('exited_at', models.DateTimeField(blank=True, default=None, help_text='Time the user exited the session', null=True, verbose_name='Exited At')), + ('is_online', models.BooleanField(default=True, help_text='Is the user currently online?', verbose_name='Is online')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('session', models.ForeignKey(help_text='Live session that the user joined.', on_delete=django.db.models.deletion.CASCADE, related_name='user_sessions', to='course.courselivesession', verbose_name='Live Session')), + ('user', models.ForeignKey(help_text='User participating in the live session.', on_delete=django.db.models.deletion.CASCADE, related_name='live_session_entries', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'User Session', + 'verbose_name_plural': 'User Sessions', + 'ordering': ('-entered_at', '-id'), + }, + ), + migrations.CreateModel( + name='LiveSessionRecording', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='Title of the recording', max_length=255, verbose_name='Title')), + ('file', models.FileField(help_text='File of the recorded session', upload_to='recorded_sessions/', verbose_name='Recording File')), + ('file_time', models.DurationField(blank=True, help_text='Duration of the recording file', null=True, verbose_name='File Duration')), + ('recording_type', models.CharField(choices=[('voice', 'Voice'), ('video', 'Video')], help_text='Type of the recording (voice or video)', max_length=10, verbose_name='Recording Type')), + ('thumbnail', models.ImageField(blank=True, help_text='Thumbnail image for video recordings', null=True, upload_to='recording_thumbnails/', verbose_name='Thumbnail')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Time the recording was created', verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='The datetime when the recording was last updated', verbose_name='Updated At')), + ('is_active', models.BooleanField(default=True, help_text='Whether this recording is active or not', verbose_name='Is Active')), + ('session', models.ForeignKey(help_text='Live session that this recording belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='course.courselivesession', verbose_name='Live Session')), + ], + options={ + 'verbose_name': 'Live Session Recording', + 'verbose_name_plural': 'Live Session Recordings', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.AddIndex( + model_name='livesessionuser', + index=models.Index(fields=['session', 'user'], name='course_live_session_b1eaa5_idx'), + ), + migrations.AddIndex( + model_name='livesessionuser', + index=models.Index(fields=['session', 'is_online'], name='course_live_session_5ef9bc_idx'), + ), + migrations.AddIndex( + model_name='livesessionuser', + index=models.Index(fields=['user', 'is_online'], name='course_live_user_id_384830_idx'), + ), + migrations.AlterUniqueTogether( + name='livesessionuser', + unique_together={('session', 'user', 'entered_at')}, + ), + migrations.AddIndex( + model_name='livesessionrecording', + index=models.Index(fields=['session', 'is_active'], name='course_live_session_f35db0_idx'), + ), + migrations.AddIndex( + model_name='livesessionrecording', + index=models.Index(fields=['session', 'recording_type'], name='course_live_session_84b2bf_idx'), + ), + migrations.AddIndex( + model_name='courselivesession', + index=models.Index(fields=['course', 'started_at'], name='course_cour_course__b8968b_idx'), + ), + migrations.AddIndex( + model_name='courselivesession', + index=models.Index(fields=['course', 'created_at'], name='course_cour_course__142085_idx'), + ), + ] diff --git a/apps/course/migrations/0009_auto_20251014_0051.py b/apps/course/migrations/0009_auto_20251014_0051.py new file mode 100644 index 0000000..3407766 --- /dev/null +++ b/apps/course/migrations/0009_auto_20251014_0051.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.4 on 2025-10-14 00:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0008_auto_20251013_1724'), + ] + + operations = [ + migrations.AddField( + model_name='courselivesession', + name='room_id', + field=models.CharField(blank=True, help_text='Identifier of the PlugNMeet room.', max_length=255, null=True, unique=True, verbose_name='Room ID'), + ), + migrations.AddIndex( + model_name='courselivesession', + index=models.Index(fields=['room_id'], name='course_cour_room_id_ed0222_idx'), + ), + ] diff --git a/apps/course/migrations/0010_courselivesession_recorded_file.py b/apps/course/migrations/0010_courselivesession_recorded_file.py new file mode 100644 index 0000000..be623b1 --- /dev/null +++ b/apps/course/migrations/0010_courselivesession_recorded_file.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2025-10-18 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0009_auto_20251014_0051'), + ] + + operations = [ + migrations.AddField( + model_name='courselivesession', + name='recorded_file', + field=models.FileField(blank=True, help_text='Recorded file of the live session.', null=True, upload_to='live_session_recordings/', verbose_name='Recorded File'), + ), + ] diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py index e86b7ee..b42b347 100644 --- a/apps/course/models/__init__.py +++ b/apps/course/models/__init__.py @@ -1,3 +1,8 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +<<<<<<< HEAD +from .participant import * +======= +from .participant import * +from .live_session import * +>>>>>>> develop diff --git a/apps/course/models/course.py b/apps/course/models/course.py index cc6b0fe..06eb96a 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -4,8 +4,11 @@ import math from django.db import models from django.db.models import TextChoices from django.utils.translation import gettext_lazy as _ +<<<<<<< HEAD from filer.fields.image import FilerImageField from filer.fields.file import FilerFileField +======= +>>>>>>> develop from apps.account.models import ProfessorUser from utils.schema import default_timing @@ -17,8 +20,16 @@ def course_file_upload_to(instance, filename): return os.path.join(f"courses/{instance.slug}/videos/{filename}") +<<<<<<< HEAD def attachment_file_upload_to(instance, filename): +======= +def attachment_file_upload_to(instance, filename): + return os.path.join(f"attachments/{filename}") + + +def course_attachment_file_upload_to(instance, filename): +>>>>>>> develop return os.path.join(f"courses/{instance.course.slug}/attachments/{filename}") @@ -32,14 +43,22 @@ class CourseCategory(models.Model): return self.name def save(self, *args, **kwargs): +<<<<<<< HEAD self.slug = generate_slug_for_model(CourseCategory, self.name) +======= + if not self.slug: + self.slug = generate_slug_for_model(CourseCategory, self.name) +>>>>>>> develop super().save(*args, **kwargs) @property def course_count(self): return self.courses.exclude(status="inactive").count() +<<<<<<< HEAD +======= +>>>>>>> develop class Course(models.Model): class LevelChoices(TextChoices): @@ -55,8 +74,13 @@ class Course(models.Model): FINISHED = 'finished', 'Finished' # Finished (course has ended)-закончился class VedioTypeChoices(models.TextChoices): +<<<<<<< HEAD VIDEO_FILE = 'video_file', 'Video File' VIDEO_LINK = 'video_link', 'Video Link' +======= + YOUTUBE_LINK = 'youtube_link', 'Youtube Link' + VIDEO_FILE = 'video_file', 'Video File' +>>>>>>> develop title = models.CharField(max_length=255, verbose_name='Course Title') @@ -68,19 +92,34 @@ class Course(models.Model): related_name="courses" ) +<<<<<<< HEAD thumbnail = FilerImageField( related_name='+', on_delete=models.PROTECT, null=True, blank=True, verbose_name=_('thumbnail') ) video_type = models.CharField(max_length=20, choices=VedioTypeChoices.choices, verbose_name='Vedio Type') +======= + thumbnail = models.ImageField(upload_to="courses/thumbnails/", verbose_name=_('Thumbnail')) + video_type = models.CharField( + max_length=20, + choices=VedioTypeChoices.choices, + verbose_name='Preview Video Type (YouTube Link or File Upload)' + ) +>>>>>>> develop video_file = models.FileField( upload_to=course_file_upload_to, null=True, blank=True ) +<<<<<<< HEAD video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Video Link') is_online = models.BooleanField(default=True, verbose_name='Is Online Course') +======= + video_link = models.CharField(max_length=500, null=True, blank=True) + + is_online = models.BooleanField(default=False, verbose_name='Is Online Course') +>>>>>>> develop online_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Online Class Link') level = models.CharField(max_length=10, choices=LevelChoices.choices, verbose_name='Course Level') duration = models.PositiveIntegerField(verbose_name='Duration (in hours)') @@ -97,7 +136,11 @@ class Course(models.Model): help_text=_('This field is automatically calculated based on the discount percentage.') ) +<<<<<<< HEAD timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing"), help_text=_("The Timing information in JSON format.")) +======= + timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing")) +>>>>>>> develop features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) @@ -114,9 +157,25 @@ class Course(models.Model): def save(self, *args, **kwargs): +<<<<<<< HEAD self.slug = generate_slug_for_model(Course, self.title) if self.discount_percentage > 0: +======= + if not self.slug: + self.slug = generate_slug_for_model(Course, self.title) + + # Ensure consistency: if price is 0, set is_free to True and discount_percentage to 0 + if self.price == 0: + self.is_free = True + self.discount_percentage = 0 + self.final_price = Decimal('0.00') + elif self.is_free: + self.price = Decimal('0.00') + self.discount_percentage = 0 + self.final_price = Decimal('0.00') + elif self.discount_percentage > 0: +>>>>>>> develop discount_amount = (self.price * self.discount_percentage) / 100 final_price = self.price - discount_amount self.final_price = Decimal(math.ceil(final_price)).quantize(Decimal('0.00')) @@ -129,6 +188,7 @@ class Course(models.Model): class Meta: verbose_name = "Course" verbose_name_plural = "Courses" +<<<<<<< HEAD @@ -150,19 +210,86 @@ class Glossary(models.Model): class Attachment(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name='Course') +======= + indexes = [ + models.Index(fields=['status']), + models.Index(fields=['is_free']), + models.Index(fields=['created_at']), + models.Index(fields=['slug']), + models.Index(fields=['status', 'created_at']), + models.Index(fields=['category', 'status']), + models.Index(fields=['professor', 'status']), + ] + + +class Glossary(models.Model): + """ + Base Glossary model that contains the actual content + """ + title = models.CharField(max_length=555, verbose_name='Glossary Title') + description = models.TextField(verbose_name='Description') + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + return self.title + + class Meta: + verbose_name = "Glossary" + verbose_name_plural = "Glossaries" + + +class CourseGlossary(models.Model): + """ + Intermediate model that connects Course with Glossary + """ + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name='Course') + glossary = models.ForeignKey(Glossary, on_delete=models.CASCADE, related_name='course_glossaries', verbose_name='Glossary') + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + return f"{self.course.title} - {self.glossary.title}" + + @property + def title(self): + return self.glossary.title + + @property + def description(self): + return self.glossary.description + + class Meta: + ordering = ("-id",) + verbose_name = "Course Glossary" + verbose_name_plural = "Course Glossaries" + + +class Attachment(models.Model): + """ + Base Attachment model that contains the actual file + """ +>>>>>>> develop title = models.CharField(max_length=255, verbose_name='Attachment Title') file = models.FileField( upload_to=attachment_file_upload_to, verbose_name='Attachment File' ) +<<<<<<< HEAD file_size = models.PositiveIntegerField(verbose_name='File Size (in bytes)', null=True, blank=True) +======= + file_size = models.PositiveIntegerField(verbose_name='File Size (in bytes)', null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) +>>>>>>> develop def save(self, *args, **kwargs): # Calculate the file size before saving if self.file and not self.file_size: self.file_size = self.file.size super().save(*args, **kwargs) +<<<<<<< HEAD def __str__(self): @@ -171,4 +298,47 @@ class Attachment(models.Model): class Meta: ordering = ("-id",) verbose_name = "Attachment" - verbose_name_plural = "Attachments" \ No newline at end of file + verbose_name_plural = "Attachments" +======= + + def __str__(self): + return self.title + + class Meta: + verbose_name = "Attachment" + verbose_name_plural = "Attachments" + + +class CourseAttachment(models.Model): + """ + Intermediate model that connects Course with Attachment + """ + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name='Course') + attachment = models.ForeignKey(Attachment, on_delete=models.CASCADE, related_name='course_attachments', verbose_name='Attachment') + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + return f"{self.course.title} - {self.attachment.title}" + + @property + def title(self): + return self.attachment.title + + @property + def file(self): + return self.attachment.file + + @property + def file_size(self): + return self.attachment.file_size + + class Meta: + ordering = ("-id",) + verbose_name = "Course Attachment" + verbose_name_plural = "Course Attachments" + indexes = [ + models.Index(fields=['course']), + models.Index(fields=['attachment']), + ] +>>>>>>> develop diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py index fd8ef10..0f0ec96 100644 --- a/apps/course/models/lesson.py +++ b/apps/course/models/lesson.py @@ -9,12 +9,17 @@ from apps.account.models import StudentUser def lesson_file_upload_to(instance, filename): +<<<<<<< HEAD return os.path.join(f"courses/{instance.course.slug}/lessons/{filename}") +======= + return os.path.join(f"lessons/{filename}") +>>>>>>> develop class Lesson(models.Model): +<<<<<<< HEAD class ContentTypeChoices(models.TextChoices): YOUTUBE_LINK = 'youtube_link', 'Youtube Link' VIDEO_FILE = 'video_file', 'Video File' @@ -25,6 +30,16 @@ class Lesson(models.Model): priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') +======= + """ + Base Lesson model that contains the actual content (video file or link) + """ + class ContentTypeChoices(models.TextChoices): + YOUTUBE_LINK = 'youtube_link', 'Youtube Link' + VIDEO_FILE = 'video_file', 'Video File' + + title = models.CharField(max_length=255, verbose_name='Lesson Title') +>>>>>>> develop content_type = models.CharField(max_length=50, choices=ContentTypeChoices.choices, verbose_name='Content Type') content_file = models.FileField( null=True, @@ -32,6 +47,7 @@ class Lesson(models.Model): upload_to=lesson_file_upload_to, ) video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Link') +<<<<<<< HEAD created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) @@ -39,27 +55,92 @@ class Lesson(models.Model): def __str__(self): return f"{self.course.title} - {self.title}" +======= + duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + return self.title + + class Meta: + verbose_name = "Lesson" + verbose_name_plural = "Lessons" + indexes = [ + models.Index(fields=['content_type']), + models.Index(fields=['created_at']), + ] + + +class CourseLesson(models.Model): + """ + Intermediate model that connects Course with Lesson + """ + course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='course_lessons', verbose_name='Lesson') + title = models.CharField(max_length=255, verbose_name='Course Lesson Title', null=True, blank=True) + priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + title = self.title or self.lesson.title + return f"{self.course.title} - {title}" +>>>>>>> develop def is_completed_by(self, student): return self.completions.filter(student=student).exists() +<<<<<<< HEAD def save(self, *args, **kwargs): print(f'---> start') +======= + @property + def content_type(self): + return self.lesson.content_type + + @property + def content_file(self): + return self.lesson.content_file + + @property + def video_link(self): + return self.lesson.video_link + + @property + def duration(self): + return self.lesson.duration + + def save(self, *args, **kwargs): + # If title is not provided, use the lesson's title + if not self.title: + self.title = self.lesson.title + +>>>>>>> develop if self.priority is None: # If priority is not set, set it to the next available priority max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority'] self.priority = (max_priority or 0) + 1 +<<<<<<< HEAD else: self._adjust_priorities() super().save(*args, **kwargs) +======= + else: + self._adjust_priorities() + super().save(*args, **kwargs) + +>>>>>>> develop def _adjust_priorities(self): # Adjust priorities of other lessons in the course lessons = self.course.lessons.exclude(pk=self.pk) # Shift priorities for lessons with the same or higher priority lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1) +<<<<<<< HEAD # # If priority is set, adjust the priorities of other lessons @@ -83,6 +164,21 @@ class Lesson(models.Model): # lesson.save(update_fields=['priority']) +======= + + class Meta: + verbose_name = "Course Lesson" + verbose_name_plural = "Course Lessons" + indexes = [ + models.Index(fields=['course']), + models.Index(fields=['lesson']), + models.Index(fields=['priority']), + models.Index(fields=['is_active']), + models.Index(fields=['course', 'priority']), + models.Index(fields=['course', 'is_active']), + ] + +>>>>>>> develop class LessonCompletion(models.Model): student = models.ForeignKey( @@ -90,8 +186,13 @@ class LessonCompletion(models.Model): on_delete=models.CASCADE, related_name='lesson_completions' ) +<<<<<<< HEAD lesson = models.ForeignKey( Lesson, +======= + course_lesson = models.ForeignKey( + CourseLesson, +>>>>>>> develop on_delete=models.CASCADE, related_name='completions' ) @@ -99,9 +200,22 @@ class LessonCompletion(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) class Meta: +<<<<<<< HEAD unique_together = ('student', 'lesson') def __str__(self): return f"{self.student.fullname} - {self.lesson.title} - Completed" +======= + unique_together = ('student', 'course_lesson') + indexes = [ + models.Index(fields=['student']), + models.Index(fields=['course_lesson']), + models.Index(fields=['completed_at']), + models.Index(fields=['student', 'course_lesson']), + ] + + def __str__(self): + return f"{self.student.fullname} - {self.course_lesson.title} - Completed" +>>>>>>> develop \ No newline at end of file diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py new file mode 100644 index 0000000..e73861b --- /dev/null +++ b/apps/course/models/live_session.py @@ -0,0 +1,187 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .course import Course +from apps.account.models import User + + +class CourseLiveSession(models.Model): + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name="live_sessions", + verbose_name=_("Course"), + help_text=_("Course that this live session belongs to."), + ) + room_id = models.CharField( + max_length=255, + verbose_name=_("Room ID"), + help_text=_("Identifier of the PlugNMeet room."), + unique=True, + null=True, + blank=True, + ) + subject = models.CharField( + max_length=255, + verbose_name=_("Subject"), + help_text=_("Topic of the live session."), + ) + started_at = models.DateTimeField( + verbose_name=_("Started At"), + help_text=_("Start time of the live session."), + ) + ended_at = models.DateTimeField( + verbose_name=_("Ended At"), + help_text=_("End time of the live session."), + null=True, + blank=True, + ) + recorded_file = models.FileField( + upload_to="live_session_recordings/", + verbose_name=_("Recorded File"), + help_text=_("Recorded file of the live session."), + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + return f"{self.course} - {self.subject}" + + class Meta: + ordering = ("-started_at", "-id") + verbose_name = _("Course Live Session") + verbose_name_plural = _("Course Live Sessions") + indexes = [ + models.Index(fields=["course", "started_at"]), + models.Index(fields=["course", "created_at"]), + models.Index(fields=["room_id"]), + ] + + +USER_ROLE_CHOICES = ( + ("participant", "Participant"), + ("moderator", "Moderator"), + ("observer", "Observer"), +) + + +class LiveSessionUser(models.Model): + session = models.ForeignKey( + CourseLiveSession, + on_delete=models.CASCADE, + related_name="user_sessions", + verbose_name=_("Live Session"), + help_text=_("Live session that the user joined."), + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="live_session_entries", + verbose_name=_("User"), + help_text=_("User participating in the live session."), + ) + role = models.CharField( + max_length=50, + choices=USER_ROLE_CHOICES, + verbose_name=_("Role"), + help_text=_("Role of the user in the session"), + ) + entered_at = models.DateTimeField( + verbose_name=_("Entered At"), + help_text=_("Time the user entered the session"), + ) + exited_at = models.DateTimeField( + verbose_name=_("Exited At"), + help_text=_("Time the user exited the session"), + null=True, + blank=True, + default=None, + ) + is_online = models.BooleanField( + default=True, + verbose_name=_("Is online"), + help_text=_("Is the user currently online?"), + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + def __str__(self): + return f"{self.user} @ {self.session}" + + class Meta: + verbose_name = _("User Session") + verbose_name_plural = _("User Sessions") + ordering = ("-entered_at", "-id") + indexes = [ + models.Index(fields=["session", "user"]), + models.Index(fields=["session", "is_online"]), + models.Index(fields=["user", "is_online"]), + ] + unique_together = ("session", "user", "entered_at") + + +RECORDING_TYPE_CHOICES = ( + ("voice", "Voice"), + ("video", "Video"), +) + + +class LiveSessionRecording(models.Model): + session = models.ForeignKey( + CourseLiveSession, + on_delete=models.CASCADE, + related_name="recordings", + verbose_name=_("Live Session"), + help_text=_("Live session that this recording belongs to."), + ) + title = models.CharField( + max_length=255, + verbose_name=_("Title"), + help_text=_("Title of the recording"), + ) + file = models.FileField( + upload_to="recorded_sessions/", + verbose_name=_("Recording File"), + help_text=_("File of the recorded session"), + ) + file_time = models.DurationField( + verbose_name=_("File Duration"), + help_text=_("Duration of the recording file"), + null=True, + blank=True, + ) + recording_type = models.CharField( + max_length=10, + choices=RECORDING_TYPE_CHOICES, + verbose_name=_("Recording Type"), + help_text=_("Type of the recording (voice or video)"), + ) + thumbnail = models.ImageField( + upload_to="recording_thumbnails/", + verbose_name=_("Thumbnail"), + help_text=_("Thumbnail image for video recordings"), + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"), help_text=_("Time the recording was created")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"), help_text=_("The datetime when the recording was last updated")) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is Active"), + help_text=_("Whether this recording is active or not"), + ) + + def __str__(self): + meet_id = getattr(self.session, "meet_id", self.session_id) + return f"meet:<{meet_id}><{self.id}>{self.title} - {self.recording_type}" + + class Meta: + verbose_name = _("Live Session Recording") + verbose_name_plural = _("Live Session Recordings") + ordering = ("-created_at", "-id") + indexes = [ + models.Index(fields=["session", "is_active"]), + models.Index(fields=["session", "recording_type"]), + ] diff --git a/apps/course/models/participant.py b/apps/course/models/participant.py index eee680a..b6c7061 100644 --- a/apps/course/models/participant.py +++ b/apps/course/models/participant.py @@ -17,8 +17,22 @@ class Participant(models.Model): on_delete=models.CASCADE, related_name='participants' ) +<<<<<<< HEAD +======= + is_active = models.BooleanField(default=True) +>>>>>>> develop joined_date = models.DateTimeField(auto_now_add=True) unread_messages_count = models.IntegerField(default=0) class Meta: - unique_together = ('student', 'course') \ No newline at end of file +<<<<<<< HEAD + unique_together = ('student', 'course') +======= + unique_together = ('student', 'course') + indexes = [ + models.Index(fields=['student']), + models.Index(fields=['course']), + models.Index(fields=['joined_date']), + models.Index(fields=['student', 'course']), + ] +>>>>>>> develop diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py index e86b7ee..affa72b 100644 --- a/apps/course/serializers/__init__.py +++ b/apps/course/serializers/__init__.py @@ -1,3 +1,9 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +<<<<<<< HEAD +from .participant import * +======= +from .participant import * +from .online import * +from .professor import * +>>>>>>> develop diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index 4c2e051..ae0e1e8 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -1,8 +1,14 @@ from rest_framework import serializers +<<<<<<< HEAD from dj_filer.admin import get_thumbs from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson +======= +# from dj_filer.admin import get_thumbs +from utils import get_thumbs +from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson, CourseAttachment, CourseGlossary, CourseLesson +>>>>>>> develop from apps.chat.models import RoomMessage from apps.account.serializers import UserProfileSerializer @@ -24,7 +30,15 @@ class CourseListSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() lessons_count = serializers.SerializerMethodField() +<<<<<<< HEAD +======= + price = serializers.SerializerMethodField() + discount_percentage = serializers.SerializerMethodField() + final_price = serializers.SerializerMethodField() + is_free = serializers.SerializerMethodField() + +>>>>>>> develop class Meta: model = Course fields = [ @@ -54,16 +68,48 @@ class CourseListSerializer(serializers.ModelSerializer): return obj.participants.count() def get_lessons_count(self, obj): +<<<<<<< HEAD lessons_count = obj.lessons.filter(is_active=True).count() return max(lessons_count, obj.lessons_count) +======= + # Use prefetched lessons if available + if hasattr(obj, 'lessons') and obj.lessons.all(): + lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active) + return max(lessons_count, obj.lessons_count) + # Fallback to direct query + lessons_count = obj.lessons.filter(is_active=True).count() + return max(lessons_count, obj.lessons_count) + + def get_price(self, obj): + if obj.is_free or obj.price == 0: + return "0.00" + return str(obj.price) + + def get_discount_percentage(self, obj): + if obj.is_free or obj.price == 0: + return 0 + return obj.discount_percentage + + def get_final_price(self, obj): + if obj.is_free or obj.price == 0: + return "0.00" + return str(obj.final_price) + + def get_is_free(self, obj): + return obj.is_free or obj.price == 0 +>>>>>>> develop class CourseDetailSerializer(serializers.ModelSerializer): category = CourseCategorySerializer() +<<<<<<< HEAD professor = UserProfileSerializer() +======= + professor = serializers.SerializerMethodField() +>>>>>>> develop thumbnail = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() access = serializers.SerializerMethodField() @@ -71,7 +117,17 @@ class CourseDetailSerializer(serializers.ModelSerializer): lessons_count = serializers.SerializerMethodField() last_lesson_id = serializers.SerializerMethodField() room_id = serializers.SerializerMethodField() +<<<<<<< HEAD +======= + user_transaction_status = serializers.SerializerMethodField() + price = serializers.SerializerMethodField() + discount_percentage = serializers.SerializerMethodField() + final_price = serializers.SerializerMethodField() + is_free = serializers.SerializerMethodField() + is_professor = serializers.SerializerMethodField() + +>>>>>>> develop class Meta: model = Course fields = [ @@ -82,6 +138,10 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'access', 'participant_count', 'professor', +<<<<<<< HEAD +======= + 'is_professor', +>>>>>>> develop 'thumbnail', 'video_type', 'video_file', @@ -103,18 +163,45 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'features', 'last_lesson_id', 'room_id', +<<<<<<< HEAD ] def get_room_id(self, obj): +======= + 'user_transaction_status' + ] + + def get_room_id(self, obj): + # Use prefetched room_messages if available + if hasattr(obj, 'room_messages') and obj.room_messages.all(): + return obj.room_messages.first().id + # Fallback to direct query if not prefetched +>>>>>>> develop room_message = RoomMessage.objects.filter(course=obj).first() if room_message: return room_message.id return None +<<<<<<< HEAD +======= + def get_user_transaction_status(self, obj): + from apps.transaction.models import TransactionParticipant + if student := self._get_authenticated_user(): + latest_transaction = TransactionParticipant.objects.filter( + user=student, + course=obj, + is_deleted=False + ).order_by('-created_at').first() + if latest_transaction: + return latest_transaction.status + return None + +>>>>>>> develop def get_last_lesson_id(self, obj): request = self.context.get('request') if request and request.user.is_authenticated: user = request.user +<<<<<<< HEAD # آخرین درس تکمیل‌شده توسط کاربر last_completed_lesson = LessonCompletion.objects.filter( @@ -131,11 +218,55 @@ class CourseDetailSerializer(serializers.ModelSerializer): ).order_by('priority').first() if not next_lesson: next_lesson = Lesson.objects.filter( +======= + + # Use prefetched lessons if available + if hasattr(obj, 'lessons') and obj.lessons.all(): + lessons = [lesson for lesson in obj.lessons.all() if lesson.is_active] + completed_lessons = [] + + # Check which lessons are completed using prefetched data + for lesson in lessons: + if hasattr(lesson, 'completions') and lesson.completions.all(): + if any(completion.student_id == user.id for completion in lesson.completions.all()): + completed_lessons.append(lesson) + + if completed_lessons: + # Find the last completed lesson by priority + last_completed = max(completed_lessons, key=lambda x: x.priority) + # Find next lesson + next_lessons = [l for l in lessons if l.priority > last_completed.priority] + if next_lessons: + return min(next_lessons, key=lambda x: x.priority).id + + # If no completed lessons or no next lesson, return first lesson + if lessons: + return min(lessons, key=lambda x: x.priority).id + + # Fallback to direct queries if not prefetched + last_completed_lesson = LessonCompletion.objects.filter( + student=user, + course_lesson__course=obj + ).order_by('-completed_at').first() + + if last_completed_lesson: + next_lesson = CourseLesson.objects.filter( + course=obj, + priority__gt=last_completed_lesson.course_lesson.priority, + is_active=True + ).order_by('priority').first() + if not next_lesson: + next_lesson = CourseLesson.objects.filter( +>>>>>>> develop course=obj, is_active=True ).order_by('priority').first() if next_lesson: +<<<<<<< HEAD return next_lesson.id +======= + return next_lesson.id +>>>>>>> develop return None @@ -146,6 +277,15 @@ class CourseDetailSerializer(serializers.ModelSerializer): return False return True return False +<<<<<<< HEAD +======= + + def get_professor(self, obj): + """Return the course professor's profile using UserProfileSerializer""" + if obj.professor: + return UserProfileSerializer(obj.professor, context=self.context).data + return None +>>>>>>> develop def get_is_professor(self, obj): if professor := self._get_authenticated_user(): @@ -153,6 +293,14 @@ class CourseDetailSerializer(serializers.ModelSerializer): return False def get_lessons_count(self, obj): +<<<<<<< HEAD +======= + # Use prefetched lessons if available + if hasattr(obj, 'lessons') and obj.lessons.all(): + lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active) + return max(lessons_count, obj.lessons_count) + # Fallback to direct query +>>>>>>> develop lessons_count = obj.lessons.filter(is_active=True).count() return max(lessons_count, obj.lessons_count) @@ -161,7 +309,14 @@ class CourseDetailSerializer(serializers.ModelSerializer): if student := self._get_authenticated_user(): if not self._is_participant(student, obj): return None +<<<<<<< HEAD return self._get_completed_lessons_count(student, obj) +======= + completed_count = self._get_completed_lessons_count(student, obj) + # Ensure completed count doesn't exceed total lessons count + total_lessons = self.get_lessons_count(obj) + return min(completed_count, total_lessons) +>>>>>>> develop return None def _is_participant(self, student, course): @@ -175,9 +330,25 @@ class CourseDetailSerializer(serializers.ModelSerializer): def _get_completed_lessons_count(self, student, course): """Helper method to count completed lessons for the student in the given course.""" +<<<<<<< HEAD return LessonCompletion.objects.filter( student=student, lesson__course=course +======= + # Use prefetched completions if available + if hasattr(course, 'lessons') and course.lessons.all(): + completed_count = 0 + for lesson in course.lessons.all(): + if hasattr(lesson, 'completions') and lesson.completions.all(): + if any(completion.student_id == student.id for completion in lesson.completions.all()): + completed_count += 1 + return completed_count + + # Fallback to direct query if not prefetched + return LessonCompletion.objects.filter( + student=student, + course_lesson__course=course +>>>>>>> develop ).count() @@ -185,17 +356,50 @@ class CourseDetailSerializer(serializers.ModelSerializer): return get_thumbs(obj.thumbnail, self.context.get('request')) def get_participant_count(self, obj): +<<<<<<< HEAD return obj.participants.count() +======= + # Use prefetched participants if available + if hasattr(obj, 'participants') and obj.participants.all(): + return len(obj.participants.all()) + # Fallback to direct query + return obj.participants.count() + + def get_price(self, obj): + if obj.is_free or obj.price == 0: + return "0.00" + return str(obj.price) + + def get_discount_percentage(self, obj): + if obj.is_free or obj.price == 0: + return 0 + return obj.discount_percentage + + def get_final_price(self, obj): + if obj.is_free or obj.price == 0: + return "0.00" + return str(obj.final_price) + def get_is_free(self, obj): + return obj.is_free or obj.price == 0 +>>>>>>> develop class MyCourseListSerializer(serializers.ModelSerializer): category = CourseCategorySerializer() thumbnail = serializers.SerializerMethodField() +<<<<<<< HEAD lessons_complated_count = serializers.SerializerMethodField() class Meta: model = Course +======= + lessons_count = serializers.SerializerMethodField() + lessons_complated_count = serializers.SerializerMethodField() + + class Meta: + model = Course +>>>>>>> develop fields = [ 'id', 'title', @@ -210,16 +414,44 @@ class MyCourseListSerializer(serializers.ModelSerializer): def get_thumbnail(self, obj): return get_thumbs(obj.thumbnail, self.context.get('request')) +<<<<<<< HEAD +======= + + def get_lessons_count(self, obj): + """Get the actual count of active lessons""" + # Use prefetched lessons if available + if hasattr(obj, 'lessons') and obj.lessons.all(): + lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active) + return max(lessons_count, obj.lessons_count) + # Fallback to direct query + lessons_count = obj.lessons.filter(is_active=True).count() + return max(lessons_count, obj.lessons_count) + +>>>>>>> develop def get_lessons_complated_count(self, obj): if student := self._get_authenticated_user(): if not self._is_participant(student, obj): return None +<<<<<<< HEAD return self._get_completed_lessons_count(student, obj) +======= + completed_count = self._get_completed_lessons_count(student, obj) + # Ensure completed count doesn't exceed total lessons count + total_lessons = self.get_lessons_count(obj) + return min(completed_count, total_lessons) +>>>>>>> develop return None def _is_participant(self, student, course): """Helper method to check if a student is a participant in the given course.""" +<<<<<<< HEAD +======= + # اگر کاربر استاد دوره است، دسترسی کامل دارد + if course.professor == student: + return True + # در غیر این صورت چک می‌کنیم که آیا participant است یا خیر +>>>>>>> develop return Participant.objects.filter(student=student, course=course).exists() def _get_authenticated_user(self): @@ -229,9 +461,25 @@ class MyCourseListSerializer(serializers.ModelSerializer): def _get_completed_lessons_count(self, student, course): """Helper method to count completed lessons for the student in the given course.""" +<<<<<<< HEAD return LessonCompletion.objects.filter( student=student, lesson__course=course +======= + # Use prefetched completions if available + if hasattr(course, 'lessons') and course.lessons.all(): + completed_count = 0 + for lesson in course.lessons.all(): + if hasattr(lesson, 'completions') and lesson.completions.all(): + if any(completion.student_id == student.id for completion in lesson.completions.all()): + completed_count += 1 + return completed_count + + # Fallback to direct query if not prefetched + return LessonCompletion.objects.filter( + student=student, + course_lesson__course=course +>>>>>>> develop ).count() @@ -239,9 +487,34 @@ class AttachmentSerializer(serializers.ModelSerializer): class Meta: model = Attachment fields = ['id', 'title', 'file', 'file_size'] +<<<<<<< HEAD +======= + + +class CourseAttachmentSerializer(serializers.ModelSerializer): + title = serializers.CharField(source='attachment.title', read_only=True) + file = serializers.FileField(source='attachment.file', read_only=True) + file_size = serializers.IntegerField(source='attachment.file_size', read_only=True) + + class Meta: + model = CourseAttachment + fields = ['id', 'title', 'file', 'file_size'] +>>>>>>> develop class GlossarySerializer(serializers.ModelSerializer): class Meta: model = Glossary +<<<<<<< HEAD +======= + fields = ['id', 'title', 'description'] + + +class CourseGlossarySerializer(serializers.ModelSerializer): + title = serializers.CharField(source='glossary.title', read_only=True) + description = serializers.CharField(source='glossary.description', read_only=True) + + class Meta: + model = CourseGlossary +>>>>>>> develop fields = ['id', 'title', 'description'] \ No newline at end of file diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index c90f900..5e66e14 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -1,4 +1,5 @@ from rest_framework import serializers +<<<<<<< HEAD from apps.course.models import Lesson, Participant, LessonCompletion from apps.quiz.serializers import QuizListSerializer @@ -14,6 +15,30 @@ class LessonSerializer(serializers.ModelSerializer): class Meta: model = Lesson fields = ['id', 'title', 'priority', 'is_active', 'permission','duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] +======= +from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion +from apps.quiz.serializers import QuizListSerializer + + +class LessonSerializer(serializers.ModelSerializer): + class Meta: + model = Lesson + fields = ['id', 'title', 'content_type', 'content_file', 'video_link', 'duration'] + + +class CourseLessonSerializer(serializers.ModelSerializer): + is_complated = serializers.SerializerMethodField() + quizs = serializers.SerializerMethodField() + permission = serializers.SerializerMethodField() + content_type = serializers.CharField(source='lesson.content_type', read_only=True) + content_file = serializers.FileField(source='lesson.content_file', read_only=True) + video_link = serializers.CharField(source='lesson.video_link', read_only=True) + duration = serializers.IntegerField(source='lesson.duration', read_only=True) + + class Meta: + model = CourseLesson + fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] +>>>>>>> develop def get_permission(self, obj): if student := self._get_authenticated_user(): @@ -34,12 +59,24 @@ class LessonSerializer(serializers.ModelSerializer): def get_is_complated(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: +<<<<<<< HEAD return False user = request.user +======= + return False + user = request.user + + # Use prefetched completions if available + if hasattr(obj, 'completions') and obj.completions.all(): + return any(completion.student_id == user.id for completion in obj.completions.all()) + + # Fallback to direct queries +>>>>>>> develop is_participant = Participant.objects.filter( student=user, course=obj.course ).exists() +<<<<<<< HEAD if not is_participant: return False @@ -55,3 +92,21 @@ class LessonSerializer(serializers.ModelSerializer): if quizzes.exists(): return QuizListSerializer(quizzes, many=True, context=self.context).data return None +======= + + if not is_participant: + return False + + return LessonCompletion.objects.filter( + student=user, + course_lesson=obj + ).exists() + + def get_quizs(self, obj): + # Now quizzes are directly related to CourseLesson + # print(f'--> type:{type(obj)} obj:{obj.quizzes.all()}') + quizzes = obj.quizzes.all() if hasattr(obj, 'quizzes') else [] + if quizzes: + return QuizListSerializer(quizzes, many=True, context=self.context).data + return None +>>>>>>> develop diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py new file mode 100644 index 0000000..3e89843 --- /dev/null +++ b/apps/course/serializers/online.py @@ -0,0 +1,67 @@ +from rest_framework import serializers +from utils import FileFieldSerializer + + +class OnlineClassTokenCreateSerializer(serializers.Serializer): + redirect_path = serializers.CharField(required=False) + + def validate_redirect_path(self, value: str) -> str: + value = value.strip() + if value and value.startswith("http"): + raise serializers.ValidationError("Redirect path must be relative to the frontend domain.") + return value + + +class OnlineClassTokenVerifySerializer(serializers.Serializer): + token = serializers.CharField(max_length=128) + + def validate_token(self, value: str) -> str: + value = value.strip() + if not value: + raise serializers.ValidationError("Token is required.") + return value + + +class LiveSessionRoomCreateSerializer(serializers.Serializer): + room_id = serializers.CharField(required=False, max_length=255, allow_blank=True) + subject = serializers.CharField(required=False, max_length=255, allow_blank=True) + + def validate_room_id(self, value: str) -> str: + return value.strip() + + def validate_subject(self, value: str) -> str: + return value.strip() + + +class LiveSessionTokenSerializer(serializers.Serializer): + course_slug = serializers.CharField(max_length=255) + + def validate_course_slug(self, value: str) -> str: + value = value.strip() + if not value: + raise serializers.ValidationError("course_slug is required.") + return value + + +class LiveSessionRecordedFileSerializer(serializers.Serializer): + recorded_file = serializers.FileField(required=True) + + def validate_recorded_file(self, value): + if not value: + raise serializers.ValidationError("recorded_file is required.") + return value + + +class LiveSessionRecordingSerializer(serializers.Serializer): + file = FileFieldSerializer(required=True) + title = serializers.CharField(required=False, max_length=255, allow_blank=True) + recording_type = serializers.ChoiceField(choices=['voice', 'video'], required=False, default='video') + file_time = serializers.DurationField(required=False, allow_null=True) + + def validate_file(self, value): + if not value: + raise serializers.ValidationError("file is required.") + return value + + def validate_title(self, value): + return value.strip() if value else None diff --git a/apps/course/serializers/professor.py b/apps/course/serializers/professor.py new file mode 100644 index 0000000..a82b212 --- /dev/null +++ b/apps/course/serializers/professor.py @@ -0,0 +1,29 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from apps.account.serializers import UserProfileSerializer +from utils import FileFieldSerializer, absolute_url + + +User = get_user_model() + + +class ProfessorListSerializer(serializers.ModelSerializer): + course_count = serializers.IntegerField(read_only=True) + lesson_count = serializers.IntegerField(read_only=True) + avatar = FileFieldSerializer(required=False) + + class Meta: + model = User + fields = ['id', 'slug', 'fullname', 'avatar','experience_years', 'course_count', 'lesson_count'] + + +class ProfessorDetailSerializer(UserProfileSerializer): + course_count = serializers.IntegerField(read_only=True) + lesson_count = serializers.IntegerField(read_only=True) + experience_years = serializers.IntegerField(read_only=True) + slug = serializers.CharField(read_only=True) + + class Meta(UserProfileSerializer.Meta): + fields = UserProfileSerializer.Meta.fields + ['slug', 'experience_years', 'course_count', 'lesson_count'] + read_only_fields = list(set(UserProfileSerializer.Meta.read_only_fields + ['slug', 'experience_years', 'course_count', 'lesson_count'])) diff --git a/apps/course/services/__init__.py b/apps/course/services/__init__.py new file mode 100644 index 0000000..2dc8b89 --- /dev/null +++ b/apps/course/services/__init__.py @@ -0,0 +1,3 @@ +from .plugnmeet import PlugNMeetClient, PlugNMeetError + +__all__ = ['PlugNMeetClient', 'PlugNMeetError'] diff --git a/apps/course/services/plugnmeet.py b/apps/course/services/plugnmeet.py new file mode 100644 index 0000000..29d0072 --- /dev/null +++ b/apps/course/services/plugnmeet.py @@ -0,0 +1,151 @@ +import json +import hmac +import hashlib +from typing import Any, Dict, Optional +from urllib.parse import urljoin + +import requests +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + + +class PlugNMeetError(Exception): + def __init__(self, message: str, *, status_code: Optional[int] = None, response_data: Optional[Dict[str, Any]] = None): + super().__init__(message) + self.status_code = status_code + self.response_data = response_data or {} + + +class PlugNMeetClient: + def __init__(self, *, base_url: Optional[str] = None, api_key: Optional[str] = None, api_secret: Optional[str] = None, timeout: Optional[float] = None): + self.base_url = (base_url or getattr(settings, "PLUGNMEET_SERVER_URL", "")).rstrip("/") + self.api_key = api_key or getattr(settings, "PLUGNMEET_API_KEY", "") + self.api_secret = api_secret or getattr(settings, "PLUGNMEET_API_SECRET", "") + self.timeout = timeout or getattr(settings, "PLUGNMEET_TIMEOUT", 10.0) + + if not self.base_url or not self.api_key or not self.api_secret: + raise ImproperlyConfigured("PlugNMeet integration settings are incomplete.") + + def create_room(self, payload: Dict[str, Any]) -> Dict[str, Any]: + # Convert entire payload keys to camelCase as required by PlugNMeet protocol + print(f"[PlugNMeet] Creating room with payload: {payload}") + prepared = self._camelize_dict(payload) + return self._post("/auth/room/create", prepared) + + def get_join_token(self, payload: Dict[str, Any]) -> Dict[str, Any]: + # Convert entire payload keys to camelCase as required by PlugNMeet protocol + prepared = self._camelize_dict(payload) + return self._post("/auth/room/getJoinToken", prepared) + + def is_room_active(self, room_id: str) -> Dict[str, Any]: + return self._post("/auth/room/isRoomActive", {"roomId": room_id}) + + def get_recording_info(self, record_id: str) -> Dict[str, Any]: + """Get detailed information about a recording.""" + return self._post("/auth/recording/recordingInfo", {"recordId": record_id}) + + def get_recording_download_token(self, record_id: str) -> Dict[str, Any]: + """Get a temporary download token for a recording.""" + return self._post("/auth/recording/getDownloadToken", {"recordId": record_id}) + + def download_file(self, download_path: str, save_to: str) -> bool: + """ + Download a file from PlugNMeet server. + + Args: + download_path: The download path (e.g., '/download/recording/token_xxx') + save_to: Local file path to save the downloaded file + + Returns: + True if download successful, False otherwise + """ + import logging + logger = logging.getLogger(__name__) + + url = urljoin(f"{self.base_url}/", download_path.lstrip("/")) + logger.info(f"[PlugNMeet] Downloading file from {url}") + + try: + response = requests.get(url, stream=True, timeout=300) # 5 minute timeout for large files + response.raise_for_status() + + # Write file in chunks + with open(save_to, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + logger.info(f"[PlugNMeet] File downloaded successfully to {save_to}") + return True + + except requests.RequestException as exc: + logger.error(f"[PlugNMeet] Failed to download file - error={str(exc)}") + raise PlugNMeetError(f"Failed to download file: {str(exc)}") from exc + + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + url = urljoin(f"{self.base_url}/", path.lstrip("/")) + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + headers = { + "Content-Type": "application/json", + "API-KEY": self.api_key, + "HASH-SIGNATURE": self._build_signature(body), + } + + import logging + logger = logging.getLogger(__name__) + logger.debug(f"[PlugNMeet] POST {path} - Body: {body[:500]}") + + try: + response = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=self.timeout) + except requests.RequestException as exc: + raise PlugNMeetError("Failed to reach PlugNMeet server.") from exc + + if response.status_code >= 400: + response_data = self._safe_json(response) + raise PlugNMeetError( + "PlugNMeet server returned an error.", + status_code=response.status_code, + response_data=response_data, + ) + + data = self._safe_json(response) + if data is None: + raise PlugNMeetError("PlugNMeet server returned an invalid response format.") + + if isinstance(data, dict) and data.get('status') is False: + error_message = data.get('msg') or data.get('message') or "PlugNMeet operation failed." + raise PlugNMeetError( + error_message, + status_code=response.status_code, + response_data=data, + ) + + return data + + @staticmethod + def _snake_to_camel(key: str) -> str: + parts = key.split("_") + if not parts: + return key + return parts[0] + "".join(p.capitalize() or "" for p in parts[1:]) + + def _camelize_dict(self, obj: Any) -> Any: + if isinstance(obj, dict): + return {self._snake_to_camel(k): self._camelize_dict(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self._camelize_dict(v) for v in obj] + return obj + + def _build_signature(self, body: str) -> str: + digest = hmac.new(self.api_secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256) + return digest.hexdigest() + + @staticmethod + def _safe_json(response: requests.Response) -> Optional[Dict[str, Any]]: + try: + return response.json() + except ValueError: + return None + + +__all__ = ["PlugNMeetClient", "PlugNMeetError"] diff --git a/apps/course/signals.py b/apps/course/signals.py index d0edf96..0d6f50d 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -1,6 +1,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver + from apps.course.models import Course from apps.chat.models import RoomMessage @@ -15,5 +16,12 @@ def create_room_message_for_course(sender, instance, created, **kwargs): course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ) + + +@receiver(post_save, sender=Course) +def ensure_professor_role(sender, instance, **kwargs): + professor = getattr(instance, 'professor', None) + if professor: + professor.ensure_professor_profile() \ No newline at end of file diff --git a/apps/course/templates/course/add_student_form.html b/apps/course/templates/course/add_student_form.html new file mode 100644 index 0000000..ecd1d31 --- /dev/null +++ b/apps/course/templates/course/add_student_form.html @@ -0,0 +1,29 @@ +{% extends "admin/base_site.html" %} + +{% load i18n unfold %} + +{% block breadcrumbs %}{% endblock %} + +{% block extrahead %} + {{ block.super }} + + {{ form.media }} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} + + {% for field in form %} + {% include "unfold/helpers/field.html" with field=field %} + {% endfor %} +
+ +
+ {% component "unfold/components/button.html" with submit=1 %} + {% trans "Submit form" %} + {% endcomponent %} +
+
+{% endblock %} diff --git a/apps/course/tests/__init__.py b/apps/course/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/course/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/course/tests/test_live_session_api.py b/apps/course/tests/test_live_session_api.py new file mode 100644 index 0000000..54ccac8 --- /dev/null +++ b/apps/course/tests/test_live_session_api.py @@ -0,0 +1,182 @@ +import tempfile +from unittest import mock + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.account.models import ProfessorUser, StudentUser +from apps.course.models import ( + Course, + CourseCategory, + CourseLiveSession, + Participant, +) + + +@override_settings( + PLUGNMEET_SERVER_URL='https://meet.example.com', + PLUGNMEET_API_KEY='test-key', + PLUGNMEET_API_SECRET='test-secret', + MEDIA_ROOT=tempfile.gettempdir(), +) +class CourseLiveSessionAPITests(APITestCase): + def setUp(self): + self.professor = ProfessorUser.objects.create( + email='prof@example.com', + fullname='Professor Sample', + experience_years=5, + ) + self.student = StudentUser.objects.create( + email='student@example.com', + fullname='Student Sample', + ) + self.category = CourseCategory.objects.create(name='Category', slug='category') + thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg') + self.course = Course.objects.create( + title='Sample Course', + slug='sample-course', + category=self.category, + professor=self.professor, + thumbnail=thumbnail, + video_type=Course.VedioTypeChoices.YOUTUBE_LINK, + video_link='https://example.com/video', + is_online=True, + online_link='https://example.com/live', + level=Course.LevelChoices.BEGINNER, + duration=10, + lessons_count=2, + description='Description', + short_description='Short', + status=Course.StatusChoices.ONGOING, + is_free=True, + ) + professor_avatar = SimpleUploadedFile('prof-avatar.jpg', b'avatar', content_type='image/jpeg') + self.professor.avatar = professor_avatar + self.professor.save(update_fields=['avatar']) + student_avatar = SimpleUploadedFile('student-avatar.jpg', b'avatar', content_type='image/jpeg') + self.student.avatar = student_avatar + self.student.save(update_fields=['avatar']) + + @mock.patch('apps.course.views.live_session.PlugNMeetClient') + def test_professor_can_create_room(self, mock_client_cls): + mock_client = mock_client_cls.return_value + mock_client.create_room.return_value = {'status': 'success'} + + self.client.force_authenticate(user=self.professor) + url = reverse('course-live-session-room-create', kwargs={'slug': self.course.slug}) + payload = { + 'room_id': 'custom-room-id', + 'subject': 'Algebra Session', + } + response = self.client.post(url, payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + mock_client.create_room.assert_called_once() + self.assertTrue( + CourseLiveSession.objects.filter(course=self.course, room_id='custom-room-id').exists() + ) + + @mock.patch('apps.course.views.live_session.PlugNMeetClient') + def test_professor_receives_admin_token(self, mock_client_cls): + mock_client = mock_client_cls.return_value + mock_client.get_join_token.return_value = {'token': 'abc123'} + + session = CourseLiveSession.objects.create( + course=self.course, + subject='Session', + started_at=timezone.now(), + room_id='room-123', + ) + + self.client.force_authenticate(user=self.professor) + url = reverse('course-live-session-token') + response = self.client.post(url, {'room_id': session.room_id}, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + args, _ = mock_client.get_join_token.call_args + payload = args[0] + self.assertTrue(payload['user_info']['is_admin']) + profile_pic = payload['user_info']['user_metadata'].get('profilePic') + self.assertEqual(profile_pic, f"http://testserver{self.professor.avatar.url}") + self.assertEqual(response.data['token'], 'abc123') + + @mock.patch('apps.course.views.live_session.PlugNMeetClient') + def test_student_participant_receives_limited_token(self, mock_client_cls): + mock_client = mock_client_cls.return_value + mock_client.get_join_token.return_value = {'token': 'student-token'} + + session = CourseLiveSession.objects.create( + course=self.course, + subject='Session', + started_at=timezone.now(), + room_id='room-456', + ) + Participant.objects.create(course=self.course, student=self.student) + + self.client.force_authenticate(user=self.student) + url = reverse('course-live-session-token') + response = self.client.post(url, {'room_id': session.room_id}, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + args, _ = mock_client.get_join_token.call_args + payload = args[0] + self.assertFalse(payload['user_info']['is_admin']) + metadata = payload['user_info']['user_metadata'] + self.assertIn('lock_microphone', metadata['lock_settings']) + self.assertEqual(metadata.get('profilePic'), f"http://testserver{self.student.avatar.url}") + self.assertEqual(response.data['token'], 'student-token') + + def test_student_without_access_cannot_get_token(self): + session = CourseLiveSession.objects.create( + course=self.course, + subject='Session', + started_at=timezone.now(), + room_id='room-789', + ) + + self.client.force_authenticate(user=self.student) + url = reverse('course-live-session-token') + response = self.client.post(url, {'room_id': session.room_id}, format='json') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_validate_metadata_includes_active_room_for_student(self): + session = CourseLiveSession.objects.create( + course=self.course, + subject='Session Live', + started_at=timezone.now(), + room_id='room-live-1', + ) + Participant.objects.create(course=self.course, student=self.student) + + self.client.force_authenticate(user=self.student) + url = reverse('course-online-validate', kwargs={'slug': self.course.slug}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + metadata = response.data['metadata'] + self.assertTrue(metadata['is_online']) + self.assertEqual(metadata['active_room_id'], session.room_id) + self.assertTrue(metadata['can_join_live_session']) + self.assertEqual(metadata['live_session']['room_id'], session.room_id) + self.assertIsNotNone(metadata['live_session']['started_at']) + + def test_validate_metadata_for_professor_hides_creation_when_online(self): + CourseLiveSession.objects.create( + course=self.course, + subject='Session Live', + started_at=timezone.now(), + room_id='room-live-2', + ) + + self.client.force_authenticate(user=self.professor) + url = reverse('course-online-validate', kwargs={'slug': self.course.slug}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + metadata = response.data['metadata'] + self.assertFalse(metadata['can_create_live_session']) diff --git a/apps/course/tests/test_multiple_roles_api.py b/apps/course/tests/test_multiple_roles_api.py new file mode 100644 index 0000000..12be84d --- /dev/null +++ b/apps/course/tests/test_multiple_roles_api.py @@ -0,0 +1,216 @@ +""" +تست‌های API برای سیستم نقش‌های چندگانه +""" +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from django.contrib.auth.models import Group +from apps.account.models import User +from apps.course.models import Course, CourseCategory, Participant +from apps.transaction.models import TransactionParticipant + + +class MultipleRolesAPITestCase(TestCase): + def setUp(self): + """راه‌اندازی داده‌های تست""" + # ایجاد گروه‌ها + Group.objects.create(name="Professor Group") + Group.objects.create(name="Student Group") + Group.objects.create(name="Client Group") + + # ایجاد کاربر + self.user = User.objects.create_user( + email='test@example.com', + fullname='Test User', + password='testpass123' + ) + + # ایجاد دسته‌بندی دوره + self.category = CourseCategory.objects.create( + name='Test Category', + slug='test-category' + ) + + # راه‌اندازی API client + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_user_profile_basic_functionality(self): + """تست عملکرد اصلی profile کاربر""" + # اضافه کردن نقش‌ها + self.user.add_role('professor') + self.user.add_role('student') + + # تست متدهای جدید User model + self.assertTrue(self.user.has_role('professor')) + self.assertTrue(self.user.has_role('student')) + + roles = self.user.get_all_roles() + self.assertIn('professor', roles) + self.assertIn('student', roles) + + # نقش اصلی باید professor باشد (اولویت بالاتر) + self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR) + + def test_course_access_for_professor(self): + """تست دسترسی استاد به دوره خودش""" + # کاربر استاد می‌شود و دوره می‌سازد + self.user.add_role('professor') + + course = Course.objects.create( + title='Test Course', + slug='test-course', + category=self.category, + professor=self.user, + level='beginner', + duration=10, + lessons_count=5, + description='Test description' + ) + + # تست serializer + from apps.course.serializers import CourseDetailSerializer + + # شبیه‌سازی request context + from django.test import RequestFactory + factory = RequestFactory() + request = factory.get('/') + request.user = self.user + + serializer = CourseDetailSerializer(course, context={'request': request}) + data = serializer.data + + # استاد باید دسترسی داشته باشد + self.assertTrue(data['access']) + + def test_course_enrollment_preserves_professor_role(self): + """تست اینکه ثبت‌نام در دوره نقش professor را حفظ می‌کند""" + # کاربر استاد می‌شود + self.user.add_role('professor') + + # کاربر دیگری دوره می‌سازد + other_user = User.objects.create_user( + email='other@example.com', + fullname='Other User', + password='testpass123' + ) + other_user.add_role('professor') + + course = Course.objects.create( + title='Test Course', + slug='test-course', + category=self.category, + professor=other_user, + level='beginner', + duration=10, + lessons_count=5, + description='Test description', + is_free=True + ) + + # شبیه‌سازی transaction + transaction_data = { + 'participant_infos': [{'email': self.user.email}] + } + + # شبیه‌سازی منطق transaction + if not self.user.has_role('student'): + self.user.add_role('student') + + Participant.objects.create( + student=self.user, + course=course + ) + + # بررسی اینکه هر دو نقش حفظ شده‌اند + self.assertTrue(self.user.has_role('professor')) + self.assertTrue(self.user.has_role('student')) + + # بررسی اینکه کاربر می‌تواند دوره خودش را مدیریت کند + own_course = Course.objects.create( + title='Own Course', + slug='own-course', + category=self.category, + professor=self.user, + level='beginner', + duration=10, + lessons_count=5, + description='Own course description' + ) + + self.assertTrue(self.user.can_manage_course(own_course)) + self.assertFalse(self.user.can_manage_course(course)) # دوره دیگری + + def test_course_access_for_professor_student(self): + """تست دسترسی دوره برای کاربری که هم استاد و هم دانش‌آموز است""" + # کاربر استاد می‌شود + self.user.add_role('professor') + + # دوره خودش + own_course = Course.objects.create( + title='Own Course', + slug='own-course', + category=self.category, + professor=self.user, + level='beginner', + duration=10, + lessons_count=5, + description='Own course description' + ) + + # دوره دیگری + other_user = User.objects.create_user( + email='other@example.com', + fullname='Other User', + password='testpass123' + ) + other_user.add_role('professor') + + other_course = Course.objects.create( + title='Other Course', + slug='other-course', + category=self.category, + professor=other_user, + level='beginner', + duration=10, + lessons_count=5, + description='Other course description' + ) + + # کاربر در دوره دیگری شرکت می‌کند + self.user.add_role('student') + Participant.objects.create( + student=self.user, + course=other_course + ) + + # تست دسترسی‌ها + from apps.course.serializers import CourseDetailSerializer + from django.test import RequestFactory + + factory = RequestFactory() + request = factory.get('/') + request.user = self.user + + # دسترسی به دوره خودش + serializer = CourseDetailSerializer(own_course, context={'request': request}) + data = serializer.data + self.assertTrue(data['access']) + + # دسترسی به دوره دیگری (به عنوان participant) + serializer = CourseDetailSerializer(other_course, context={'request': request}) + data = serializer.data + self.assertTrue(data['access']) + + def test_backward_compatibility(self): + """تست سازگاری با کدهای قدیمی""" + # property قدیمی باید همچنان کار کند + self.user.add_role('student') + self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT) + + self.user.add_role('professor') + self.assertEqual(self.user.user_type_based_on_groups, User.UserType.PROFESSOR) + + # user_type field باید بروزرسانی شود + self.assertEqual(self.user.user_type, User.UserType.PROFESSOR) diff --git a/apps/course/tests/test_professor_api.py b/apps/course/tests/test_professor_api.py new file mode 100644 index 0000000..72bd79d --- /dev/null +++ b/apps/course/tests/test_professor_api.py @@ -0,0 +1,113 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from rest_framework.test import APITestCase + +from apps.account.models import ProfessorUser +from apps.course.models import Course, CourseCategory, CourseLesson, Lesson + + +class TestProfessorAPI(APITestCase): + def setUp(self): + self.professor = ProfessorUser.objects.create( + email='professor@example.com', + fullname='استاد نمونه', + experience_years=7, + ) + self.category = CourseCategory.objects.create(name='General', slug='general') + thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg') + self.course = Course.objects.create( + title='Test Course', + slug='test-course', + category=self.category, + professor=self.professor, + thumbnail=thumbnail, + video_type=Course.VedioTypeChoices.YOUTUBE_LINK, + video_link='https://example.com/video', + is_online=True, + online_link='https://example.com/classroom', + level=Course.LevelChoices.BEGINNER, + duration=10, + lessons_count=1, + description='Sample description', + short_description='Short description', + status=Course.StatusChoices.ONGOING, + is_free=True, + ) + lesson = Lesson.objects.create( + title='Lesson 1', + content_type=Lesson.ContentTypeChoices.VIDEO_FILE, + duration=5, + ) + CourseLesson.objects.create(course=self.course, lesson=lesson, priority=1, is_active=True) + self.professor.refresh_from_db() + + def test_professor_list_api(self): + url = reverse('course-professor-list') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 1) + item = response.data['results'][0] + self.assertEqual(item['slug'], self.professor.slug) + self.assertEqual(item['fullname'], self.professor.fullname) + self.assertEqual(item['experience_years'], 7) + self.assertEqual(item['course_count'], 1) + self.assertEqual(item['lesson_count'], 1) + + def test_professor_detail_api(self): + url = reverse('course-professor-detail', kwargs={'slug': self.professor.slug}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.data + self.assertEqual(data['slug'], self.professor.slug) + self.assertEqual(data['fullname'], self.professor.fullname) + self.assertEqual(data['experience_years'], 7) + self.assertEqual(data['course_count'], 1) + self.assertEqual(data['lesson_count'], 1) + + def test_professor_courses_api(self): + url = reverse('course-professor-course-list', kwargs={'slug': self.professor.slug}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 1) + course_data = response.data['results'][0] + self.assertEqual(course_data['id'], self.course.id) + self.assertEqual(course_data['title'], self.course.title) + + def test_professor_slug_generated_without_fullname(self): + professor = ProfessorUser.objects.create( + email='slugless@example.com', + fullname='', + ) + self.assertTrue(professor.slug) + + def test_course_creation_promotes_professor_user(self): + professor = ProfessorUser.objects.create( + email='pending@example.com', + fullname='کاربر موقت', + ) + professor.user_type = ProfessorUser.UserType.CLIENT + professor.slug = None + professor.save(update_fields=['user_type', 'slug']) + + thumbnail = SimpleUploadedFile('thumb2.jpg', b'filecontent', content_type='image/jpeg') + Course.objects.create( + title='Auto Promote Course', + slug='auto-promote-course', + category=self.category, + professor=professor, + thumbnail=thumbnail, + video_type=Course.VedioTypeChoices.YOUTUBE_LINK, + video_link='https://example.com/video2', + is_online=False, + level=Course.LevelChoices.BEGINNER, + duration=5, + lessons_count=0, + description='Test', + short_description='Test', + status=Course.StatusChoices.REGISTERING, + is_free=True, + ) + + professor.refresh_from_db() + self.assertEqual(professor.user_type, ProfessorUser.UserType.PROFESSOR) + self.assertTrue(professor.slug) diff --git a/apps/course/token-join-guide.md b/apps/course/token-join-guide.md new file mode 100644 index 0000000..9e0e157 --- /dev/null +++ b/apps/course/token-join-guide.md @@ -0,0 +1,312 @@ +# راهنمای گرفتن توکن و ورود کلاینت به کلاس‌های plugNmeet + +این راهنما خلاصه می‌کند که برای سناریوی استاد/دانشجو چگونه از سرویس plugNmeet توکن بگیریم و کلاینت فرانت‌اند (`client/`) با آن وارد کلاس شود. + +## پیش‌نیازها +- آدرس سرویس: `window.PLUG_N_MEET_SERVER_URL = "https://meet.newhorizonco.uk"` (در `config.js`). +- `api_key` و `secret` از فایل پیکربندی بک‌اند (`services/plugnmeet-server/config.yaml`). +- بدنهٔ درخواست‌ها باید با پروتکل JSON متناظر با پیام‌های پروتوباف (`plugnmeet-protocol`) ارسال شود؛ سرور طبق `HandleAuthHeaderCheck` هدرهای امنیتی را بررسی می‌کند. + +## گام ۱: ایجاد یا فعال بودن اتاق + +### API Endpoint برای Django Backend: +``` +POST /api/courses//online/room/create/ +``` + +### بدنه درخواست از فرانت به Django: +```json +{ + "subject": "کلاس جبر فصل ۱" // اختیاری - عنوان روم +} +``` + +**⚠️ نکات مهم:** +- **فرانت نباید `metadata` ارسال کند!** +- بک‌اند Django (در `apps/course/views/live_session.py`) به‌طور خودکار تنظیمات امنیتی را اعمال می‌کند +- این تضمین می‌کند که تنظیمات امنیتی به‌صورت متمرکز و یکسان اعمال شود + +### بدنه درخواست از Django به PlugNMeet (خودکار): +بک‌اند Django این بدنه را خودش به PlugNMeet ارسال می‌کند: + +```json +{ + "room_id": "algebra-1402", + "metadata": { + "room_title": "کلاس جبر فصل ۱", + "default_lock_settings": { + "lock_microphone": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند + "lock_webcam": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند + "lock_screen_sharing": true // 🔒 قفل - فقط میزبان می‌تواند باز کند + }, + "room_features": { + "mute_on_start": true, // 🔇 همه با میک خاموش وارد می‌شوند + "waiting_room_features": { + "is_active": false + } + } + } +} +``` + +> **چرا بک‌اند این کار را می‌کند؟** +> - ✅ **امنیت متمرکز**: تنظیمات امنیتی در یک جا کنترل می‌شود +> - ✅ **جلوگیری از دستکاری**: فرانت نمی‌تواند تنظیمات را تغییر دهد +> - ✅ **یکپارچگی**: همه کلاس‌ها با تنظیمات یکسان ساخته می‌شوند +> - 🔒 طبق تابع `AssignLockSettingsToUser` در `pkg/models/user_lock.go` این مقادیر برای کاربران غیر-admin اعمال می‌شود + +## گام ۲: گرفتن توکن ورود + +### API Endpoint برای Django Backend: +``` +POST /api/courses/online/room/token/ +``` + +### درخواست از فرانت به Django: +``` +Headers: + Authorization: Token + Content-Type: application/json + +Body: +{ + "course_slug": "algebra-10" +} +``` + +**⚠️ نکات مهم:** +- **فرانت فقط `course_slug` ارسال می‌کند!** +- بک‌اند Django از `Authorization` header کاربر را شناسایی می‌کند +- بک‌اند خودش live session فعال دوره را پیدا می‌کند: + ```python + # 1. پیدا کردن دوره + course = Course.objects.get(slug=course_slug) + + # 2. پیدا کردن live session فعال + session = CourseLiveSession.objects.get( + course=course, + ended_at__isnull=True # session هایی که هنوز به پایان نرسیده‌اند + ) + + # 3. گرفتن room_id + room_id = session.room_id + ``` +- بک‌اند خودش همه اطلاعات کاربر را می‌سازد: + - `user_id` از `request.user` + - `name` از `user.get_full_name()` یا `user.email` + - `is_admin` از `user.can_manage_course(course)` + - `profilePic` از `user.avatar` + - `lock_settings` برای غیر-admin + +### بدنه درخواست از Django به PlugNMeet (خودکار): + +بک‌اند Django این payload را خودش می‌سازد و به PlugNMeet می‌فرستد: + +**برای استاد:** +```json +{ + "room_id": "algebra-1402", + "user_info": { + "user_id": "10", // 🔐 از request.user + "name": "استاد نمونه", // 🔐 از user.get_full_name() + "is_admin": true, // 🔐 از user.can_manage_course() + "user_metadata": { + "is_hidden": false, + "profilePic": "https://..." // 🔐 از user.avatar + } + } +} +``` + +**برای دانشجو:** +```json +{ + "room_id": "algebra-1402", + "user_info": { + "user_id": "27", // 🔐 از request.user + "name": "دانشجو نمونه", // 🔐 از user.get_full_name() + "is_admin": false, // 🔐 از user.can_manage_course() + "user_metadata": { + "profilePic": "https://...", // 🔐 از user.avatar + "lock_settings": { // 🔒 خودکار برای غیر-admin + "lock_microphone": true, + "lock_screen_sharing": true, + "lock_webcam": true + } + } + } +} +``` + +### نحوه کار بک‌اند Django: +```python +# 1. شناسایی کاربر از token +user = request.user # از Authorization header + +# 2. پیدا کردن دوره و session فعال +course = Course.objects.get(slug=course_slug) +session = CourseLiveSession.objects.get(course=course, ended_at__isnull=True) +room_id = session.room_id + +# 3. تشخیص نقش +is_admin = user.can_manage_course(course) # استاد یا مالک دوره + +# 4. ساخت user_info +user_info = { + 'user_id': str(user.id), + 'name': user.get_full_name() or user.email, + 'is_admin': is_admin, +} + +# 4. اضافه کردن profilePic +profile_pic = request.build_absolute_uri(user.avatar.url) +user_metadata['profilePic'] = profile_pic + +# 5. اضافه کردن lock_settings برای غیر-admin +if not is_admin: + user_metadata['lock_settings'] = { + 'lock_microphone': True, + 'lock_screen_sharing': True, + 'lock_webcam': True, + } +``` + +### ارسال به PlugNMeet: +بک‌اند Django با هدرهای امنیتی به PlugNMeet ارسال می‌کند: +- `API-KEY`: از settings +- `HASH-SIGNATURE`: `HMAC_SHA256(body, secret)` +- این توکن JWT اختصاصی plugNmeet است که در `GeneratePNMJoinToken` ساخته می‌شود +- `is_admin: true` باعث می‌شود در `GetPNMJoinToken` کاربر به عنوان presenter با تمام دسترسی‌ها ثبت شود +- `lock_settings` باعث می‌شود در فرانت‌اند PlugNMeet دکمه‌های میکروفون/وبکم غیرفعال شوند + +### پاسخ Django به فرانت: +```json +{ + "room_id": "algebra-1402", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "plugnmeet": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires": 300, + ... + } +} +``` + +فرانت با این `token` می‌تواند کاربر را به PlugNMeet وارد کند: +``` +https://meet.newhorizonco.uk/?access_token= +``` + +## گام ۳: ورود کلاینت با توکن +۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئری‌استرینگ یا از کوکی `pnm_access_token` می‌خواند (`getAccessToken` در `client/src/helpers/utils.ts`). +۲. آدرس ورود: `https://meet.newhorizonco.uk/?access_token=`. +۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری: + - درخواست `POST /api/verifyToken` را با هدر `Authorization: ` می‌فرستد (`HandleVerifyToken`). + - اگر توکن معتبر باشد، لیست آدرس‌های NATS و موضوعات لازم را می‌گیرد و اتصال را آغاز می‌کند (`startNatsConn`). +۴. پس از اتصال، وضعیت کاربر و اتاق در Redux ذخیره می‌شود (`sessionSlice`). اگر کاربر ادمین باشد، تمام امکانات بدون محدودیت فعال است؛ در غیر این صورت مقدارهای `lock_settings` تعیین می‌کنند چه دکمه‌هایی فعال باشند. + +## کنترل حالت صحبت/شنیدن برای استاد و دانشجو + +### استاد (Moderator/Host): +- ✅ در توکن `is_admin: true` ارسال می‌شود +- ✅ بک‌اند Django در `apps/course/views/live_session.py` این را تشخیص می‌دهد: + ```python + is_admin = user.can_manage_course(course) # استاد یا مالک دوره + ``` +- ✅ سرور PlugNMeet در `GetPNMJoinToken` رول presenter را فعال می‌کند +- ✅ **هیچ قفلی** روی میکروفون، وبکم یا اشتراک صفحه اعمال نمی‌شود +- 🎤 استاد می‌تواند بلافاصله صحبت کند و به دانشجو **اجازه صحبت** دهد + +### دانشجو (Participant): +- 🔒 در توکن `is_admin: false` ارسال می‌شود +- 🔒 بک‌اند Django خودکار lock_settings را اضافه می‌کند: + ```python + if not is_admin: + user_metadata['lock_settings'] = { + 'lock_microphone': True, + 'lock_screen_sharing': True, + 'lock_webcam': True, + } + ``` +- 🔇 دکمه‌های میکروفون، وبکم و اشتراک صفحه **غیرفعال** هستند +- 👂 فقط می‌تواند **گوش دهد** تا میزبان اجازه دهد +- این منطق در `joinModal.tsx` با متغیر `isMicLock` پیاده‌سازی شده است + +### نحوه دادن اجازه به دانشجو: +- میزبان باید از داخل کلاس از طریق UI کنترل کند +- یا از API `/api/updateLockSettings` یا `switchPresenter` استفاده کند + +## نکات تکمیلی + +### توکن‌ها و انقضا: +- توکن‌ها زمان انقضای مفهومی دارند (`client.token_validity` در YAML) +- در صورت نزدیک شدن به انقضا، کلاینت خودکار با `REQ_RENEW_PNM_TOKEN` درخواست تمدید می‌دهد + +### Authorization: +- برای درخواست‌های بعدی به `/api/...` همان هدر `Authorization` را ست کنید +- کلاینت این کار را در `helpers/api/plugNmeetAPI.ts` انجام می‌دهد + +### مدیریت دسترسی‌ها: +- اگر می‌خواهید دانشجو را به صحبت‌کننده ارتقا دهید: `/api/updateLockSettings` یا `switchPresenter` +- این کار فقط توسط **میزبان** امکان‌پذیر است + +## 🔐 جمع‌بندی امنیت + +### ❌ چیزهایی که فرانت نباید انجام دهد: + +#### موقع ساخت روم: +- ❌ ارسال `metadata` +- ❌ ارسال `default_lock_settings` +- ❌ ارسال `room_features` + +#### موقع گرفتن توکن: +- ❌ ارسال `room_id` (بک‌اند خودش از session فعال می‌گیرد) +- ❌ ارسال `user_info` +- ❌ ارسال `is_admin` +- ❌ ارسال `lock_settings` +- ❌ ارسال `user_id` یا `name` + +### ✅ چیزهایی که فرانت فقط ارسال می‌کند: + +#### موقع ساخت روم: +```json +{ + "room_id": "algebra-1402", // اختیاری + "subject": "کلاس جبر" // اختیاری +} +``` + +#### موقع گرفتن توکن: +```json +{ + "course_slug": "algebra-10" // فقط این! +} +``` ++ `Authorization: Token ` در header + +### ✅ چیزهایی که بک‌اند Django خودش انجام می‌دهد: + +#### برای همه درخواست‌ها: +- ✅ شناسایی کاربر از `Authorization` header +- ✅ بررسی دسترسی با `user.can_manage_course()` یا `Participant.objects.filter()` + +#### موقع ساخت روم: +- ✅ تعیین `default_lock_settings` (همه `true`) +- ✅ تعیین `room_features.mute_on_start: true` +- ✅ ساخت `metadata` کامل برای PlugNMeet + +#### موقع گرفتن توکن: +- ✅ پیدا کردن live session فعال از `course_slug` +- ✅ گرفتن `room_id` از session +- ✅ ساخت `user_id` از `request.user.id` +- ✅ ساخت `name` از `user.get_full_name()` یا `user.email` +- ✅ تشخیص `is_admin` از `user.can_manage_course(course)` +- ✅ گرفتن `profilePic` از `user.avatar` +- ✅ اضافه کردن `lock_settings` برای غیر-admin +- ✅ ساخت `user_info` کامل برای PlugNMeet + +**نتیجه:** +- 🔒 **امنیت کامل**: فرانت نمی‌تواند هیچ تنظیمات امنیتی را دستکاری کند +- ✅ **متمرکز**: همه logic در بک‌اند Django است +- 🎯 **ساده**: فرانت فقط `course_slug` و `Authorization` header ارسال می‌کند +- 🔐 **قابل کنترل**: بک‌اند تعیین می‌کند کدام session فعال است diff --git a/apps/course/urls.py b/apps/course/urls.py index ff96734..8028538 100644 --- a/apps/course/urls.py +++ b/apps/course/urls.py @@ -1,5 +1,5 @@ -from django.urls import path +from django.urls import path, re_path from . import views @@ -9,15 +9,27 @@ urlpatterns = [ path('categories/', views.CourseCategoryAPIView.as_view(), name='course-categories'), path('', views.CourseListAPIView.as_view(), name='course-list'), path('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'), - path('lesson/completion/', views.LessonCompletionCreateAPIView.as_view(), name='lesson-completion'), + path('lesson/completion/', views.LessonCompletionToggleAPIView.as_view(), name='lesson-completion'), + path('professors/', views.ProfessorListAPIView.as_view(), name='course-professor-list'), + re_path(r'professors/(?P[\w-]+)/courses/$', views.ProfessorCourseListAPIView.as_view(), name='course-professor-course-list'), + re_path(r'professors/(?P[\w-]+)/$', views.ProfessorDetailAPIView.as_view(), name='course-professor-detail'), + path('/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'), + re_path(r'(?P[\w-]+)/online/validate/$', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-validate'), + path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'), + re_path(r'(?P[\w-]+)/online/room/create/$', views.CourseLiveSessionRoomCreateAPIView.as_view(), name='course-live-session-room-create'), + path('online/room/token/', views.CourseLiveSessionTokenAPIView.as_view(), name='course-live-session-token'), + path('/live-sessions/recorded-file/', views.CourseLiveSessionRecordedFileAPIView.as_view(), name='course-live-session-recorded-file'), - path('/', views.CourseDetailAPIView.as_view(), name='course-detail'), - path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), - path('/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), - path('/lessons/', views.LessonListView.as_view(), name='course-lesson-list'), + # PlugNMeet webhook endpoint + path('plugnmeet/webhook/', views.PlugNMeetWebhookAPIView.as_view(), name='plugnmeet-webhook'), + + re_path(r'(?P[\w-]+)/$', views.CourseDetailAPIView.as_view(), name='course-detail'), + re_path(r'(?P[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), + re_path(r'(?P[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), + re_path(r'(?P[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'), path('lesson//', views.LessonDetailView.as_view(), name='lesson-detail'), - path('/participants/', views.CourseParticipantsView.as_view(), name='course-participant-list'), + re_path(r'(?P[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'), # path('/participant/join/', views.ParticipantCreateView.as_view(), name='course-participant-join'), diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py index e86b7ee..23ff190 100644 --- a/apps/course/views/__init__.py +++ b/apps/course/views/__init__.py @@ -1,3 +1,10 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +<<<<<<< HEAD +from .participant import * +======= +from .participant import * +from .professor import * +from .live_session import * +from .webhook import * +>>>>>>> develop diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 04f7acd..5a29484 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from rest_framework.generics import ListAPIView, RetrieveAPIView from django.db.models import Count, Q, F from drf_yasg.utils import swagger_auto_schema @@ -5,14 +6,58 @@ from drf_yasg import openapi from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated from rest_framework.filters import SearchFilter +======= +from django.conf import settings +import logging + +from django.contrib.auth import get_user_model +from django.db.models import Count, Q, F +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import NotFound +from rest_framework.filters import SearchFilter +from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +logger = logging.getLogger(__name__) +>>>>>>> develop from apps.course.serializers import ( CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, +<<<<<<< HEAD AttachmentSerializer, GlossarySerializer, MyCourseListSerializer ) from apps.course.models import Course, CourseCategory, Attachment, Glossary, Participant from apps.course.doc import * +======= + CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer, + OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer +) +from apps.course.models import ( + Course, + CourseAttachment, + CourseCategory, + CourseGlossary, + CourseLiveSession, + LiveSessionUser, + Participant, +) +from apps.course.doc import * +from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError +from apps.account.serializers import UserProfileSerializer +from utils.exceptions import AppAPIException +from utils.redis import OnlineClassTokenManager + + +UserModel = get_user_model() +>>>>>>> develop class CourseCategoryAPIView(ListAPIView): @@ -21,6 +66,10 @@ class CourseCategoryAPIView(ListAPIView): @swagger_auto_schema( operation_description=doc_course_category(), +<<<<<<< HEAD +======= + tags=["Imam-Javad - Course"] +>>>>>>> develop ) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) @@ -28,6 +77,7 @@ class CourseCategoryAPIView(ListAPIView): +<<<<<<< HEAD class CourseListAPIView(ListAPIView): queryset = Course.objects.all().exclude(status=Course.StatusChoices.INACTIVE) serializer_class = CourseListSerializer @@ -42,11 +92,39 @@ class CourseListAPIView(ListAPIView): description="Category of the Course", type=openapi.TYPE_STRING, enum=[category.slug for category in CourseCategory.objects.all()] +======= +from utils.pagination import StandardResultsSetPagination + +class CourseListAPIView(ListAPIView): + serializer_class = CourseListSerializer + filter_backends = [SearchFilter] + search_fields = ['title', 'category__name', 'professor__fullname'] + pagination_class = StandardResultsSetPagination + + @swagger_auto_schema( + tags=['Imam-Javad - Course'], + operation_description=doc_course_list(), + manual_parameters=[ + openapi.Parameter( + 'search', openapi.IN_QUERY, + description="Search by course title, category name, or professor's full name", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + 'category_slug', openapi.IN_QUERY, + description="Category of the Course", + type=openapi.TYPE_STRING, + # enum=[category.slug for category in CourseCategory.objects.all()] +>>>>>>> develop ), openapi.Parameter( 'status', openapi.IN_QUERY, type=openapi.TYPE_STRING, +<<<<<<< HEAD description="""Status => +======= + description="""Status => +>>>>>>> develop Upcoming (visible but registration not allowed)---Предстоящие Registering (registration is open)---регистрация Ongoing (course has started, registration closed)---Впроцессе @@ -66,6 +144,7 @@ class CourseListAPIView(ListAPIView): ), ]) def get(self, request, *args, **kwargs): +<<<<<<< HEAD return super().get(request, *args, **kwargs) def get_queryset(self): @@ -73,6 +152,22 @@ class CourseListAPIView(ListAPIView): request = self.request filters = request.query_params +======= + return self.list(request, *args, **kwargs) + + def get_queryset(self): + """ + Optimized queryset with select_related for ForeignKey relationships and filtering + """ + queryset = Course.objects.select_related( + 'category', + 'professor' + ).exclude(status=Course.StatusChoices.INACTIVE) + + request = self.request + filters = request.query_params + +>>>>>>> develop # Handle category_slug with multiple values separated by commas if category_slugs := filters.get('category_slug'): category_slugs_list = category_slugs.split(',') @@ -82,7 +177,11 @@ class CourseListAPIView(ListAPIView): if statuses := filters.get('status'): statuses_list = statuses.split(',') queryset = queryset.filter(status__in=statuses_list) +<<<<<<< HEAD +======= + +>>>>>>> develop if is_free := filters.get('is_free'): is_free = is_free.lower() == 'true' queryset = queryset.filter( @@ -91,7 +190,11 @@ class CourseListAPIView(ListAPIView): if is_online := filters.get('is_online'): is_online = is_online.lower() == 'true' queryset = queryset.filter(is_online=is_online) +<<<<<<< HEAD +======= + +>>>>>>> develop return queryset @@ -99,12 +202,49 @@ class CourseListAPIView(ListAPIView): class CourseDetailAPIView(RetrieveAPIView): +<<<<<<< HEAD queryset = Course.objects.all() +======= +>>>>>>> develop serializer_class = CourseDetailSerializer lookup_field = "slug" + @swagger_auto_schema( +<<<<<<< HEAD + operation_description=doc_course_detail(), +======= + tags=["Imam-Javad - Course"], + operation_description="Get detailed information about a specific course", + responses={ + 200: openapi.Response( + description="Course details", + schema=CourseDetailSerializer() + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + """ + Optimized queryset with select_related and prefetch_related for all relationships + """ + return Course.objects.select_related( + 'category', + 'professor' + ).prefetch_related( + 'lessons__lesson', + 'lessons__completions', + 'attachments__attachment', + 'glossaries__glossary', + 'participants__student', + 'room_messages' + ) + @swagger_auto_schema( operation_description=doc_course_detail(), + tags=['Imam-Javad - Course'], +>>>>>>> develop ) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) @@ -127,6 +267,10 @@ class MyCourseListAPIView(ListAPIView): ], operation_description=doc_courses_my_courses(), operation_summary="Home", +<<<<<<< HEAD +======= + tags=['Imam-Javad - Course'] +>>>>>>> develop ) def get(self, request, *args, **kwargs): @@ -134,7 +278,22 @@ class MyCourseListAPIView(ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): +<<<<<<< HEAD queryset = Course.objects.exclude(status=Course.StatusChoices.INACTIVE) +======= + """ + Optimized queryset for user's courses with select_related and prefetch_related + """ + queryset = Course.objects.select_related( + 'category', + 'professor' + ).prefetch_related( + 'lessons__lesson', + 'lessons__completions', + 'participants__student' + ).exclude(status=Course.StatusChoices.INACTIVE) + +>>>>>>> develop request = self.request filters = request.query_params student = self.request.user @@ -175,9 +334,16 @@ class MyCourseListAPIView(ListAPIView): class AttachmentListAPIView(ListAPIView): +<<<<<<< HEAD serializer_class = AttachmentSerializer @swagger_auto_schema( +======= + serializer_class = CourseAttachmentSerializer + + @swagger_auto_schema( + tags=['Imam-Javad - Course'], +>>>>>>> develop manual_parameters=[ openapi.Parameter( 'slug', openapi.IN_PATH, @@ -192,29 +358,419 @@ class AttachmentListAPIView(ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): +<<<<<<< HEAD +======= + """ + Optimized queryset with select_related for attachment relationship + """ +>>>>>>> develop course_slug = self.kwargs.get('slug') try: course = Course.objects.get(slug=course_slug) except Course.DoesNotExist: raise NotFound("Course not found") +<<<<<<< HEAD return Attachment.objects.filter(course=course) +======= + return CourseAttachment.objects.select_related( + 'course', + 'attachment' + ).filter(course=course) +>>>>>>> develop class GlossaryListAPIView(ListAPIView): +<<<<<<< HEAD serializer_class = GlossarySerializer filter_backends = [SearchFilter] search_fields = ['title', 'description'] def get_queryset(self): +======= + serializer_class = CourseGlossarySerializer + filter_backends = [SearchFilter] + search_fields = ['glossary__title', 'glossary__description'] + + @swagger_auto_schema( + operation_description="Get glossary terms for a specific course", + tags=["Imam-Javad - Course"], + manual_parameters=[ + openapi.Parameter( + 'slug', openapi.IN_PATH, + description="Course slug", + type=openapi.TYPE_STRING, + required=True + ), + openapi.Parameter( + 'search', openapi.IN_QUERY, + description="Search in glossary title or description", + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + 200: openapi.Response( + description="List of glossary terms", + schema=CourseGlossarySerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + """ + Optimized queryset with select_related for glossary relationship + """ +>>>>>>> develop course_slug = self.kwargs.get('slug') try: course = Course.objects.get(slug=course_slug) except Course.DoesNotExist: raise NotFound("Course not found") +<<<<<<< HEAD return Glossary.objects.filter(course=course) - \ No newline at end of file + +======= + return CourseGlossary.objects.select_related( + 'course', + 'glossary' + ).filter(course=course) + + + +class CourseOnlineClassTokenAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = OnlineClassTokenCreateSerializer + + @swagger_auto_schema( + tags=['Imam-Javad - Course'], + operation_description="Generate a temporary entry token for an online class.", + request_body=OnlineClassTokenCreateSerializer, + responses={ + status.HTTP_201_CREATED: openapi.Response( + description="Token generated successfully.", + examples={ + "application/json": { + "token": "abc123xyz789...", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics", + "expires_in": 300, + } + } + ) + } + ) + def post(self, request, pk, *args, **kwargs): + serializer = self.get_serializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + course = get_object_or_404(Course, pk=pk) + if not course.is_online: + raise AppAPIException({'message': "Course is not marked as online."}, status_code=status.HTTP_400_BAD_REQUEST) + + if not self._user_has_access(request.user, course): + raise AppAPIException({'message': "You do not have access to this course."}, status_code=status.HTTP_403_FORBIDDEN) + + manager = OnlineClassTokenManager() + user_token, _ = Token.objects.get_or_create(user=request.user) + identifier = f"{request.user.id}:{user_token.key[:8]}" + token = manager.generate_token(course_id=course.id, user_identifier=identifier) + + manager.store_token(token, { + 'course_id': course.id, + 'user_id': request.user.id, + 'user_token': user_token.key, + 'course_slug': course.slug, + 'extra': { + 'professor_in_class': False, + }, + }) + + # ساخت URL ثابت با token و course slug + entry_url = f"https://imamjavad.newhorizonco.uk/join-class?token={token}&slug={course.slug}" + + return Response({ + 'token': token, + 'url': entry_url, + 'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300), + }, status=status.HTTP_201_CREATED) + + @staticmethod + def _user_has_access(user, course: Course) -> bool: + if user.is_staff or course.professor_id == user.id: + return True + return Participant.objects.filter(course=course, student=user).exists() + + +class CourseOnlineClassTokenValidateAPIView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = OnlineClassTokenVerifySerializer + + def get_permissions(self): + if self.request.method == 'GET': + return [IsAuthenticated()] + return [AllowAny()] + + @swagger_auto_schema( + tags=['Imam-Javad - Course'], + operation_description="Get course and user data for authenticated user.", + manual_parameters=[ + openapi.Parameter( + 'slug', openapi.IN_PATH, + description="Course Slug", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Course data retrieved.", + examples={ + "application/json": { + "course": {"id": 1, "title": "Sample Course"}, + "user": {"id": 10, "fullname": "John Doe"}, + "metadata": { + "status": "ongoing", + "has_started": True, + "professor_in_class": False, + "validated_at": "2024-01-01T10:00:00Z" + } + } + } + ) + } + ) + def get(self, request, slug, *args, **kwargs): + detail_view = CourseDetailAPIView() + queryset = detail_view.get_queryset() + course = get_object_or_404(queryset, slug=slug) + user = request.user + + # DEPRECATED: Polling approach replaced by webhook integration + # Room status is now updated automatically via PlugNMeet webhooks + # self._sync_room_status_with_plugnmeet(course) + + course_data = CourseDetailSerializer(course, context={'request': request}).data + user_data = UserProfileSerializer(user, context={'request': request}).data + metadata = self._build_metadata( + course, + {'user_id': user.id, 'extra': {}, 'generated_at': timezone.now().isoformat()}, + user=user, + ) + + return Response({ + 'course': course_data, + 'user': user_data, + 'metadata': metadata, + }, status=status.HTTP_200_OK) + + @swagger_auto_schema( + tags=['Imam-Javad - Course'], + operation_description="Validate an online class entry token and return course/user data.", + request_body=OnlineClassTokenVerifySerializer, + responses={ + status.HTTP_200_OK: openapi.Response( + description="Token validated.", + examples={ + "application/json": { + "course": {"id": 1, "title": "Sample Course"}, + "user": {"id": 10, "fullname": "John Doe"}, + "metadata": { + "status": "ongoing", + "has_started": True, + "professor_in_class": False, + "validated_at": "2024-01-01T10:00:00Z" + } + } + } + ) + } + ) + def post(self, request, *args, **kwargs): + logger.info(f"[Online Validate] Request received") + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + token_value = serializer.validated_data['token'] + manager = OnlineClassTokenManager() + + try: + payload = manager.get_payload(token_value) + logger.info(f"[Online Validate] Token decoded successfully") + except Exception as e: + logger.error(f"[Online Validate] Token decode failed - error={str(e)}") + raise + + course_id = payload.get('course_id') + user_id = payload.get('user_id') + if not course_id or not user_id: + logger.warning(f"[Online Validate] Invalid token payload - course_id={course_id} user_id={user_id}") + raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST) + + logger.info(f"[Online Validate] Processing for user_id={user_id} course_id={course_id}") + + detail_view = CourseDetailAPIView() + queryset = detail_view.get_queryset() + course = get_object_or_404(queryset, pk=course_id) + user = get_object_or_404(UserModel.objects.all(), pk=user_id) + + logger.info(f"[Online Validate] Course found - slug={course.slug} is_online={course.is_online}") + + course_data = CourseDetailSerializer(course, context={'request': request}).data + user_data = UserProfileSerializer(user, context={'request': request}).data + metadata = self._build_metadata(course, payload, user=user) + + logger.info(f"[Online Validate] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") + + return Response({ + 'course': course_data, + 'user': user_data, + 'metadata': metadata, + }, status=status.HTTP_200_OK) + + def _build_metadata(self, course: Course, payload: dict, user=None) -> dict: + status_value = course.status + has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED] + timing_data = course.timing if isinstance(course.timing, dict) else {} + + user = user or UserModel.objects.filter(pk=payload.get('user_id')).first() + user_id = getattr(user, 'id', None) + can_manage = bool(user and user.can_manage_course(course)) + + live_context = self._build_live_session_context(course) + can_join_live_session = live_context['is_online'] and self._user_can_join_live_session(user, course) + + logger.debug(f"[Online Validate Metadata] user_id={user_id} course={course.slug} can_manage={can_manage} is_online={live_context['is_online']} can_join={can_join_live_session}") + + metadata = { + 'status': status_value, + 'has_started': has_started, + 'has_finished': status_value == Course.StatusChoices.FINISHED, + 'professor_in_class': payload.get('extra', {}).get('professor_in_class', False), + 'can_create_live_session': can_manage and not live_context['is_online'], + 'can_join_live_session': can_join_live_session, + 'scheduled_times': timing_data, + 'generated_at': payload.get('generated_at'), + 'validated_at': timezone.now().isoformat(), + 'redirect_path': payload.get('redirect_path'), + } + + metadata.update(live_context) + return metadata + + def _build_live_session_context(self, course: Course) -> dict: + latest_session = ( + CourseLiveSession.objects.filter(course=course) + .order_by('-started_at', '-id') + .first() + ) + + if not latest_session: + return { + 'is_online': False, + 'live_session': None, + 'active_room_id': None, + 'livesession_started_at': None, + 'livesession_ended_at': None, + } + + started_at = latest_session.started_at + ended_at = latest_session.ended_at + is_online = bool(started_at and not ended_at) + + live_session_data = { + 'id': latest_session.id, + 'room_id': latest_session.room_id, + 'subject': latest_session.subject, + 'started_at': self._format_datetime(started_at), + 'ended_at': self._format_datetime(ended_at), + } + + return { + 'is_online': is_online, + 'live_session': live_session_data, + 'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None, + 'livesession_started_at': live_session_data['started_at'], + 'livesession_ended_at': live_session_data['ended_at'], + } + + @staticmethod + def _user_can_join_live_session(user, course: Course) -> bool: + if not user: + return False + if user.can_manage_course(course): + return True + return Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists() + + @staticmethod + def _format_datetime(value): + if not value: + return None + if isinstance(value, str): + return value + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone.get_current_timezone()) + return timezone.localtime(value).isoformat() + + # DEPRECATED: This polling approach is inefficient and has been replaced by webhook integration + # def _sync_room_status_with_plugnmeet(self, course: Course): + # """ + # Check if active live session's room is still active in PlugNMeet. + # If room is inactive, close the session and all related user entries. + # + # DEPRECATED: This should be replaced by webhook integration. + # PlugNMeet now sends webhooks when rooms end, eliminating the need for polling. + # """ + # active_session = CourseLiveSession.objects.filter( + # course=course, + # ended_at__isnull=True + # ).first() + # + # if not active_session or not active_session.room_id: + # return + # + # try: + # client = PlugNMeetClient() + # response = client.is_room_active(active_session.room_id) + # is_active = response.get('isActive', False) + # + # if not is_active: + # logger.info(f"[Room Sync] Room inactive in PlugNMeet - room_id={active_session.room_id} session_id={active_session.id}") + # self._close_live_session(active_session) + # else: + # logger.debug(f"[Room Sync] Room still active - room_id={active_session.room_id} session_id={active_session.id}") + # + # except (PlugNMeetError, Exception) as e: + # logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}") + + @staticmethod + def _close_live_session(session: CourseLiveSession): + """ + Close a live session and all related user entries. + Sets ended_at for session and exited_at/is_online for users. + """ + now = timezone.now() + + session.ended_at = now + session.save(update_fields=['ended_at', 'updated_at']) + logger.info(f"[Room Sync] Session closed - session_id={session.id} room_id={session.room_id} ended_at={now}") + + updated_count = LiveSessionUser.objects.filter( + session=session, + is_online=True, + exited_at__isnull=True + ).update( + is_online=False, + exited_at=now, + updated_at=now + ) + + if updated_count > 0: + logger.info(f"[Room Sync] User sessions closed - session_id={session.id} count={updated_count}") +>>>>>>> develop diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py index a4958a3..39ea533 100644 --- a/apps/course/views/lesson.py +++ b/apps/course/views/lesson.py @@ -7,9 +7,15 @@ from rest_framework import status from rest_framework.response import Response from apps.course.serializers import ( +<<<<<<< HEAD LessonSerializer ) from apps.course.models import Course, Lesson, LessonCompletion +======= + CourseLessonSerializer +) +from apps.course.models import Course, CourseLesson, LessonCompletion +>>>>>>> develop from apps.course.doc import * from utils.exceptions import AppAPIException from rest_framework.permissions import IsAuthenticated @@ -17,16 +23,25 @@ from rest_framework.permissions import IsAuthenticated class LessonListView(ListAPIView): +<<<<<<< HEAD serializer_class = LessonSerializer queryset = Lesson.objects.filter(is_active=True) @swagger_auto_schema( operation_description=doc_courses_lesson(), +======= + serializer_class = CourseLessonSerializer + + @swagger_auto_schema( + operation_description=doc_courses_lesson(), + tags=['Imam-Javad - Course'], +>>>>>>> develop ) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_queryset(self): +<<<<<<< HEAD course_slug = self.kwargs.get('slug') course = get_object_or_404(Course, slug=course_slug) course = Course.objects.filter(slug=course_slug).first() @@ -34,11 +49,30 @@ class LessonListView(ListAPIView): raise AppAPIException({"message": "course not found"}, status_code=status.HTTP_404_NOT_FOUND) return self.queryset.filter(course=course).order_by('priority','id') +======= + """ + Optimized queryset with select_related and prefetch_related for lesson relationships + """ + course_slug = self.kwargs.get('slug') + course = get_object_or_404(Course, slug=course_slug) + + return CourseLesson.objects.select_related( + 'course', + 'lesson' + ).prefetch_related( + 'completions', + 'quizzes' + ).filter( + course=course, + is_active=True + ).order_by('priority', 'id') +>>>>>>> develop class LessonDetailView(RetrieveAPIView): +<<<<<<< HEAD serializer_class = LessonSerializer def get(self, request, *args, **kwargs): @@ -56,12 +90,62 @@ class LessonDetailView(RetrieveAPIView): previous_lesson_id = previous_lesson.id if previous_lesson else None lesson_data = self.get_serializer(lesson).data +======= + serializer_class = CourseLessonSerializer + + @swagger_auto_schema( + operation_description="Get detailed lesson information with navigation data", + tags=["Imam-Javad - Course"], + manual_parameters=[ + openapi.Parameter( + 'id', openapi.IN_PATH, + description="Lesson ID", + type=openapi.TYPE_INTEGER, + required=True + ) + ], + responses={ + 200: openapi.Response( + description="Lesson details with navigation information", + schema=CourseLessonSerializer() + ) + } + ) + def get(self, request, *args, **kwargs): + """ + Optimized lesson detail view with select_related for relationships + """ + lesson_id = self.kwargs.get('id') + course_lesson = get_object_or_404( + CourseLesson.objects.select_related('course', 'lesson'), + id=lesson_id, + is_active=True + ) + + course = course_lesson.course + lessons = CourseLesson.objects.select_related( + 'lesson' + ).filter( + course=course, + is_active=True + ).order_by('priority') + + total_lessons = lessons.count() + current_lesson_number = list(lessons.values_list('id', flat=True)).index(course_lesson.id) + 1 + next_lesson = lessons.filter(priority__gt=course_lesson.priority).order_by('priority').first() + next_lesson_id = next_lesson.id if next_lesson else None + previous_lesson = lessons.filter(priority__lt=course_lesson.priority).order_by('-priority').first() + previous_lesson_id = previous_lesson.id if previous_lesson else None + + lesson_data = self.get_serializer(course_lesson).data +>>>>>>> develop lesson_data['total_lessons'] = total_lessons lesson_data['current_lesson_number'] = current_lesson_number lesson_data['next_lesson_id'] = next_lesson_id lesson_data['previous_lesson_id'] = previous_lesson_id lesson_data['can_go_next'] = next_lesson is not None +<<<<<<< HEAD @@ -99,35 +183,60 @@ class LessonDetailView(RetrieveAPIView): # lesson_data['previous_lesson_id'] = previous_lesson.id if previous_lesson else None +======= +>>>>>>> develop return Response(lesson_data) +<<<<<<< HEAD class LessonCompletionCreateAPIView(GenericAPIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( +======= +class LessonCompletionToggleAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description="Toggle lesson completion status (Check/Uncheck)", + tags=["Imam-Javad - Course"], +>>>>>>> develop request_body=openapi.Schema( type=openapi.TYPE_OBJECT, required=['lesson_id'], properties={ +<<<<<<< HEAD 'lesson_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the lesson to be marked as completed'), }, ), responses={ 201: 'Lesson completed successfully.', 200: 'Lesson already completed.', +======= + 'lesson_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the lesson to toggle'), + }, + ), + responses={ + 201: 'Lesson marked as COMPLETED.', + 200: 'Lesson marked as INCOMPLETE (Unchecked).', +>>>>>>> develop 400: 'Lesson ID is required.', 404: 'Lesson not found.', } ) def post(self, request): +<<<<<<< HEAD student = request.user # Assuming the user is the student +======= + student = request.user +>>>>>>> develop lesson_id = request.data.get('lesson_id') if not lesson_id: return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST) +<<<<<<< HEAD try: lesson = Lesson.objects.get(id=lesson_id) except Lesson.DoesNotExist: @@ -141,4 +250,32 @@ class LessonCompletionCreateAPIView(GenericAPIView): completion = LessonCompletion(student=student, lesson=lesson) completion.save() - return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED) \ No newline at end of file + return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED) +======= + + try: + course_lesson = CourseLesson.objects.get(id=lesson_id) + except CourseLesson.DoesNotExist: + return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND) + + # TOGGLE LOGIC + # Try to find an existing completion record + completion = LessonCompletion.objects.filter(student=student, course_lesson=course_lesson).first() + + if completion: + # Scenario: The user clicked by mistake or wants to un-check + # Action: Delete the record + completion.delete() + return Response( + {'message': 'Lesson marked as incomplete.', 'is_completed': False}, + status=status.HTTP_200_OK + ) + else: + # Scenario: The lesson is not finished yet + # Action: Create the record + LessonCompletion.objects.create(student=student, course_lesson=course_lesson) + return Response( + {'message': 'Lesson completed successfully.', 'is_completed': True}, + status=status.HTTP_201_CREATED + ) +>>>>>>> develop diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py new file mode 100644 index 0000000..2ea2a11 --- /dev/null +++ b/apps/course/views/live_session.py @@ -0,0 +1,368 @@ +import logging + +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording +from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer +from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError +from utils.exceptions import AppAPIException + +logger = logging.getLogger(__name__) + + +class CourseLiveSessionRoomCreateAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = LiveSessionRoomCreateSerializer + + @swagger_auto_schema( + operation_description="Create a live session room for a course", + tags=["Imam-Javad - Course"], + manual_parameters=[ + openapi.Parameter( + 'slug', openapi.IN_PATH, + description="Course slug", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={ + 201: openapi.Response( + description="Live session room created successfully" + ) + } + ) + def post(self, request, slug, *args, **kwargs): + logger.info(f"[LiveSession Create] Request from user_id={request.user.id} for course={slug}") + + data = dict(request.data or {}) + if 'metadata' in data: + logger.warning("[LiveSession Create] 'metadata' provided by client will be ignored for security reasons.") + data.pop('metadata', None) + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + + course = get_object_or_404(Course, slug=slug) + + if not request.user.can_manage_course(course): + logger.warning(f"[LiveSession Create] Permission denied - user_id={request.user.id} course={slug}") + raise AppAPIException({'message': 'You do not have permission to create a live session for this course.'}, status_code=status.HTTP_403_FORBIDDEN) + + logger.info(f"[LiveSession Create] Permission granted for user_id={request.user.id} course={slug}") + + subject = serializer.validated_data.get('subject') or f"{course.title} Live Session" + room_id = self._build_room_id(course) + metadata = self._build_metadata(subject) + + payload = { + 'room_id': room_id, + 'metadata': metadata, + } + + logger.info(f"[LiveSession Create] Calling PlugNMeet API - room_id={room_id} course={slug}") + + try: + client = PlugNMeetClient() + plugnmeet_response = client.create_room(payload) + logger.info(f"[LiveSession Create] PlugNMeet room created successfully - room_id={room_id}") + except ImproperlyConfigured as exc: + logger.error(f"[LiveSession Create] Configuration error - {str(exc)}") + raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + except PlugNMeetError as exc: + logger.error(f"[LiveSession Create] PlugNMeet API error - room_id={room_id} error={str(exc)}") + detail = exc.response_data or {'message': str(exc)} + status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY + raise AppAPIException(detail, status_code=status_code) + + session, created = CourseLiveSession.objects.get_or_create( + course=course, + room_id=room_id, + defaults={ + 'subject': subject, + 'started_at': timezone.now(), + }, + ) + + if created: + logger.info(f"[LiveSession Create] New session created - session_id={session.id} room_id={room_id} course={slug}") + else: + logger.info(f"[LiveSession Create] Existing session reactivated - session_id={session.id} room_id={room_id} course={slug}") + updates = {} + if session.subject != subject: + session.subject = subject + updates['subject'] = subject + if session.room_id != room_id: + session.room_id = room_id + updates['room_id'] = room_id + if session.started_at is None: + session.started_at = timezone.now() + updates['started_at'] = session.started_at + if updates: + session.save(update_fields=list(updates.keys())) + logger.info(f"[LiveSession Create] Session updated - session_id={session.id} fields={list(updates.keys())}") + + logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} course={slug} user_id={request.user.id}") + + return Response({ + 'session': { + 'id': session.id, + 'room_id': session.room_id, + 'subject': session.subject, + 'started_at': session.started_at, + }, + 'plugnmeet': plugnmeet_response, + }, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) + + @staticmethod + def _build_room_id(course: Course) -> str: + return f"{course.id}-imamjavad" + + def _build_metadata(self, subject: str) -> dict: + # Build secured, centralized metadata. Client overrides are NOT allowed. + return { + 'room_title': subject, + 'default_lock_settings': { + 'lock_microphone': True, + 'lock_webcam': True, + 'lock_screen_sharing': True, + 'lock_whiteboard': False, + 'lock_shared_notepad': False, + 'lock_chat': False, + 'lock_chat_send_message': False, + 'lock_chat_file_share': False, + 'lock_private_chat': False, + }, + 'room_features': { + 'allow_webcams': True, + 'mute_on_start': True, + 'allow_screen_sharing': True, + 'allow_recording': True, + 'allow_rtmp': False, + 'allow_view_other_webcams': True, + 'allow_view_other_participants_list': True, + 'admin_only_webcams': False, + 'allow_polls': True, + 'room_duration': 0, + 'chat_features': { + 'allow_chat': True, + 'allow_file_upload': True, + }, + 'shared_note_pad_features': { + 'allowed_shared_note_pad': True, + }, + 'whiteboard_features': { + 'allowed_whiteboard': True, + }, + 'breakout_room_features': { + 'is_allow': True, + 'allowed_number_rooms': 6, + }, + 'waiting_room_features': { + 'is_active': False, + }, + 'recording_features': { + 'is_allow': True, + 'is_allow_cloud': True, + 'is_allow_local': True, + 'enable_auto_cloud_recording': False, + 'only_record_admin_webcams': False, + }, + }, + } + + def _deep_update(self, base: dict, overrides: dict) -> dict: + for key, value in overrides.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = self._deep_update(base.get(key, {}), value) + else: + base[key] = value + return base + + +class CourseLiveSessionTokenAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = LiveSessionTokenSerializer + + @swagger_auto_schema( + operation_description="Generate access token for live session", + tags=["Imam-Javad - Course"], + responses={ + 200: openapi.Response( + description="Live session token generated successfully" + ) + } + ) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + course_slug = serializer.validated_data['course_slug'] + user = request.user + + logger.info(f"[LiveSession Token] Request from user_id={user.id} for course={course_slug}") + + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + logger.warning(f"[LiveSession Token] Course not found - course={course_slug} user_id={user.id}") + raise AppAPIException({'message': 'Course not found.'}, status_code=status.HTTP_404_NOT_FOUND) + + if not course.is_online: + logger.warning(f"[LiveSession Token] Course not configured for online - course={course_slug} user_id={user.id}") + raise AppAPIException({'message': 'Course is not configured for online sessions.'}, status_code=status.HTTP_400_BAD_REQUEST) + + try: + session = CourseLiveSession.objects.select_related('course').get( + course=course, + ended_at__isnull=True + ) + logger.info(f"[LiveSession Token] Active session found - session_id={session.id} room_id={session.room_id} course={course_slug}") + except CourseLiveSession.DoesNotExist: + logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}") + raise AppAPIException({'message': 'No active live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) + + room_id = session.room_id + + is_admin = user.can_manage_course(course) + user_role = "professor" if is_admin else "student" + logger.info(f"[LiveSession Token] User role determined - user_id={user.id} role={user_role} course={course_slug}") + + if not is_admin and not Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists(): + logger.warning(f"[LiveSession Token] Access denied - user_id={user.id} not enrolled in course={course_slug}") + raise AppAPIException({'message': 'You do not have access to this live session.'}, status_code=status.HTTP_403_FORBIDDEN) + + user_info = { + 'user_id': str(user.id), + 'name': user.get_full_name() or user.email or user.username or f"user-{user.id}", + 'is_admin': is_admin, + } + + user_metadata = {} + profile_pic = self._build_profile_url(request, user) + logger.info(f"[LiveSession Token] Profile pic URL - user_id={user.id} url={profile_pic}") + if profile_pic: + user_metadata['profilePic'] = profile_pic + + if not is_admin: + user_metadata['lock_settings'] = { + 'lock_microphone': True, + 'lock_screen_sharing': True, + 'lock_webcam': True, + 'lock_whiteboard': False, + 'lock_shared_notepad': False, + 'lock_chat': False, + 'lock_chat_send_message': False, + 'lock_chat_file_share': False, + 'lock_private_chat': False, + } + else: + user_metadata['is_hidden'] = False + + if user_metadata: + user_info['user_metadata'] = user_metadata + + payload = { + 'room_id': room_id, + 'user_info': user_info, + } + + logger.info(f"[LiveSession Token] Requesting token from PlugNMeet - room_id={room_id} user_id={user.id} role={user_role}") + + try: + client = PlugNMeetClient() + plugnmeet_response = client.get_join_token(payload) + logger.info(f"[LiveSession Token] Token generated successfully - room_id={room_id} user_id={user.id}") + except ImproperlyConfigured as exc: + logger.error(f"[LiveSession Token] Configuration error - {str(exc)}") + raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + except PlugNMeetError as exc: + logger.error(f"[LiveSession Token] PlugNMeet API error - room_id={room_id} user_id={user.id} error={str(exc)}") + detail = exc.response_data or {'message': str(exc)} + status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY + raise AppAPIException(detail, status_code=status_code) + + logger.info(f"[LiveSession Token] Success - room_id={room_id} user_id={user.id} role={user_role} course={course_slug}") + + return Response({ + 'room_id': room_id, + 'token': plugnmeet_response.get('token'), + 'plugnmeet': plugnmeet_response, + }) + + @staticmethod + def _build_profile_url(request, user): + avatar = getattr(user, 'avatar', None) + if avatar and getattr(avatar, 'url', None): + return request.build_absolute_uri(avatar.url) + return None + + +class CourseLiveSessionRecordedFileAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = LiveSessionRecordingSerializer + + @swagger_auto_schema( + operation_description="Update recorded file for live session", + tags=["Imam-Javad - Course"], + manual_parameters=[ + openapi.Parameter( + 'course_id', openapi.IN_PATH, + description="Course ID", + type=openapi.TYPE_INTEGER, + required=True + ) + ], + responses={ + 200: openapi.Response( + description="Recorded file updated successfully" + ) + } + ) + def patch(self, request, course_id, *args, **kwargs): + logger.info(f"[LiveSession Recorded File] Request from user_id={request.user.id} for course_id={course_id}") + + course = get_object_or_404(Course, id=course_id) + + if not request.user.can_manage_course(course): + logger.warning(f"[LiveSession Recorded File] Permission denied - user_id={request.user.id} course_id={course_id}") + raise AppAPIException({'message': 'You do not have permission to update this course.'}, status_code=status.HTTP_403_FORBIDDEN) + + try: + session = course.live_sessions.latest('-started_at') + except CourseLiveSession.DoesNotExist: + logger.warning(f"[LiveSession Recorded File] No active session found - course_id={course_id} user_id={request.user.id}") + raise AppAPIException({'message': 'No live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) + + logger.info(f"[LiveSession Recorded File] Latest session found - session_id={session.id} course_id={course_id}") + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + recording = LiveSessionRecording.objects.create( + session=session, + file=serializer.validated_data['file'], + title=serializer.validated_data.get('title') or f"{session.subject} Recording", + recording_type=serializer.validated_data.get('recording_type', 'video'), + file_time=serializer.validated_data.get('file_time'), + ) + + logger.info(f"[LiveSession Recorded File] Recording created successfully - recording_id={recording.id} session_id={session.id} user_id={request.user.id}") + + return Response({ + 'id': recording.id, + 'session_id': session.id, + 'title': recording.title, + 'file': request.build_absolute_uri(recording.file.url), + 'recording_type': recording.recording_type, + 'file_time': recording.file_time, + 'is_active': recording.is_active, + }, status=status.HTTP_201_CREATED) diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py index a89e9ae..3188d82 100644 --- a/apps/course/views/participant.py +++ b/apps/course/views/participant.py @@ -19,15 +19,32 @@ class CourseParticipantsView(generics.ListAPIView): @swagger_auto_schema( operation_description=doc_course_participants(), +<<<<<<< HEAD ) def get_queryset(self): +======= + tags=['Imam-Javad - Course'], + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + def get_queryset(self): + """ + Optimized queryset with select_related for course relationship + """ +>>>>>>> develop course_slug = self.kwargs.get('slug') try: course = Course.objects.get(slug=course_slug) except Course.DoesNotExist: raise AppAPIException({'message': "Course not found"}) # Handle course not found +<<<<<<< HEAD return StudentUser.objects.filter(participated_courses__course=course) +======= + return StudentUser.objects.select_related().filter( + participated_courses__course=course + ) +>>>>>>> develop diff --git a/apps/course/views/professor.py b/apps/course/views/professor.py new file mode 100644 index 0000000..4b51193 --- /dev/null +++ b/apps/course/views/professor.py @@ -0,0 +1,174 @@ +from django.contrib.auth import get_user_model +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404 + +from rest_framework.filters import SearchFilter +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.permissions import AllowAny +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema + +from apps.course.models import Course +from apps.course.serializers import ( + CourseListSerializer, + ProfessorDetailSerializer, + ProfessorListSerializer, +) + + +UserModel = get_user_model() + + +class ProfessorListAPIView(ListAPIView): + permission_classes = [AllowAny] + serializer_class = ProfessorListSerializer + filter_backends = [SearchFilter] + search_fields = ['fullname', 'email'] + + @swagger_auto_schema( + operation_description='دریافت فهرست استادها به همراه تعداد دوره‌ها و درس‌های فعال هر استاد.', + tags=['Imam-Javad - Course'], + responses={ + 200: openapi.Response( + description='فهرست صفحه‌بندی‌شده‌ی استادها.', + schema=ProfessorListSerializer(many=True), + examples={ + 'application/json': { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'id': 7, + 'slug': 'dr-rahimi', + 'fullname': 'دکتر رحیمی', + 'experience_years': 10, + 'course_count': 4, + 'lesson_count': 56, + } + ], + } + }, + ) + }, + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return ( + UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR) + .annotate( + course_count=Count('courses', distinct=True), + lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True), + ) + .order_by('fullname') + ) + + +class ProfessorDetailAPIView(RetrieveAPIView): + permission_classes = [AllowAny] + serializer_class = ProfessorDetailSerializer + lookup_field = 'slug' + + @swagger_auto_schema( + operation_description='دریافت جزئیات یک استاد بر اساس اسلاگ.', + tags=['Imam-Javad - Course'], + responses={ + 200: openapi.Response( + description='اطلاعات کامل استاد.', + schema=ProfessorDetailSerializer(), + examples={ + 'application/json': { + 'id': 7, + 'device_id': 'abc-123', + 'fcm': None, + 'fullname': 'دکتر رحیمی', + 'avatar': None, + 'email': 'rahimi@example.com', + 'phone_number': '+989121234567', + 'password': None, + 'info': 'متخصص فیزیک پزشکی.', + 'skill': 'فیزیک، تدریس آنلاین', + 'city': 'تهران', + 'country': 'ایران', + 'birthdate': '1985-04-12', + 'gender': 'male', + 'slug': 'dr-rahimi', + 'experience_years': 10, + 'course_count': 4, + 'lesson_count': 56, + } + }, + ) + }, + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR).annotate( + course_count=Count('courses', distinct=True), + lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True), + ) + + +class ProfessorCourseListAPIView(ListAPIView): + permission_classes = [AllowAny] + serializer_class = CourseListSerializer + filter_backends = [SearchFilter] + search_fields = ['title', 'category__name'] + + @swagger_auto_schema( + operation_description='دریافت فهرست دوره‌های فعال یک استاد مشخص‌شده با اسلاگ.', + tags=['Imam-Javad - Course'], + responses={ + 200: openapi.Response( + description='فهرست صفحه‌بندی‌شده‌ی دوره‌ها.', + schema=CourseListSerializer(many=True), + examples={ + 'application/json': { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'id': 42, + 'title': 'فیزیک پایه', + 'slug': 'basic-physics', + 'participant_count': 150, + 'category': { + 'name': 'علوم پایه', + 'slug': 'basic-science', + 'course_count': 12, + }, + 'thumbnail': None, + 'is_online': True, + 'online_link': 'https://example.com/live/basic-physics', + 'level': 'beginner', + 'duration': '12h', + 'lessons_count': 24, + 'short_description': 'مروری بر مفاهیم پایه فیزیک.', + 'status': 'published', + 'is_free': False, + 'price': '250000', + 'discount_percentage': 20, + 'final_price': '200000', + } + ], + } + }, + ) + }, + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + slug = self.kwargs.get('slug') + professor = get_object_or_404(UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR, slug=slug)) + return Course.objects.select_related('category', 'professor').prefetch_related( + 'lessons__lesson', + 'lessons__completions', + 'participants__student', + ).exclude(status=Course.StatusChoices.INACTIVE).filter(professor=professor) diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py new file mode 100644 index 0000000..68e3f64 --- /dev/null +++ b/apps/course/views/webhook.py @@ -0,0 +1,575 @@ +import json +import hmac +import hashlib +import logging +from typing import Dict, Any + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt + +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant +from apps.account.models import User +from utils.exceptions import AppAPIException + +logger = logging.getLogger(__name__) + + +@method_decorator(csrf_exempt, name='dispatch') +class PlugNMeetWebhookAPIView(APIView): + """ + Webhook endpoint to receive events from PlugNMeet server. + + Events handled: + - room_finished: Close the live session + - participant_joined: Create LiveSessionUser entry + - participant_left: Mark LiveSessionUser as offline/exited + - end_recording: (Future implementation) + """ + permission_classes = [AllowAny] + + @swagger_auto_schema( + operation_description="Handle webhook events from PlugNMeet server for live sessions", + tags=["Imam-Javad - Course"], + responses={ + 200: openapi.Response(description="Webhook processed successfully"), + 400: openapi.Response(description="Invalid webhook signature or data"), + 500: openapi.Response(description="Internal server error") + } + ) + def post(self, request, *args, **kwargs): + logger.info(f"[PlugNMeet Webhook] Received webhook request") + + # Verify webhook signature + if not self._verify_webhook_signature(request): + logger.warning(f"[PlugNMeet Webhook] Invalid signature") + raise AppAPIException( + {'message': 'Invalid webhook signature'}, + status_code=status.HTTP_403_FORBIDDEN + ) + + try: + payload = json.loads(request.body.decode('utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error(f"[PlugNMeet Webhook] Invalid JSON payload - error={str(e)}") + raise AppAPIException( + {'message': 'Invalid JSON payload'}, + status_code=status.HTTP_400_BAD_REQUEST + ) + + event = payload.get('event') + if not event: + logger.warning(f"[PlugNMeet Webhook] Missing event field") + raise AppAPIException( + {'message': 'Missing event field'}, + status_code=status.HTTP_400_BAD_REQUEST + ) + + logger.info(f"[PlugNMeet Webhook] Processing event={event}") + + # Route to appropriate handler + handler_map = { + 'room_finished': self._handle_room_finished, + 'participant_joined': self._handle_participant_joined, + 'participant_left': self._handle_participant_left, + 'end_recording': self._handle_end_recording, + } + + handler = handler_map.get(event) + if not handler: + logger.info(f"[PlugNMeet Webhook] Unhandled event={event}, ignoring") + return Response({'status': 'ok', 'message': f'Event {event} ignored'}) + + try: + result = handler(payload) + logger.info(f"[PlugNMeet Webhook] Event processed successfully - event={event}") + return Response({'status': 'ok', **result}) + except Exception as e: + logger.error(f"[PlugNMeet Webhook] Error processing event={event} - error={str(e)}", exc_info=True) + return Response( + {'status': 'error', 'message': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + def _verify_webhook_signature(self, request) -> bool: + """ + Verify webhook signature using SHA256 HMAC. + Expects Hash-Token header with SHA256 signature of request body. + """ + hash_token = request.headers.get('Hash-Token') + if not hash_token: + logger.warning(f"[PlugNMeet Webhook] Missing Hash-Token header") + return False + + # Get API secret from settings + api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) + if not api_secret: + logger.error(f"[PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") + raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured") + + # Calculate expected signature + body = request.body + expected_signature = hmac.new( + api_secret.encode('utf-8'), + body, + hashlib.sha256 + ).hexdigest() + + # Compare signatures (constant time comparison) + is_valid = hmac.compare_digest(hash_token, expected_signature) + + if not is_valid: + logger.warning(f"[PlugNMeet Webhook] Signature mismatch - expected={expected_signature[:10]}... got={hash_token[:10]}...") + + return is_valid + + def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Handle room_finished event: Close the live session and all user sessions. + + Payload structure: + { + "event": "room_finished", + "room": { + "sid": "room-123456", + "identity": "course-slug-20240101120000", + "name": "کلاس جبر", + "duration": 3600 + } + } + """ + room_data = payload.get('room', {}) + room_id = room_data.get('identity') + + if not room_id: + logger.warning(f"[PlugNMeet Webhook] room_finished: Missing room identity") + return {'message': 'Missing room identity'} + + logger.info(f"[PlugNMeet Webhook] room_finished - room_id={room_id}") + + try: + session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) + except CourseLiveSession.DoesNotExist: + logger.warning(f"[PlugNMeet Webhook] room_finished: Session not found or already ended - room_id={room_id}") + return {'message': 'Session not found or already ended'} + + # Close the session + now = timezone.now() + session.ended_at = now + session.save(update_fields=['ended_at', 'updated_at']) + logger.info(f"[PlugNMeet Webhook] Session closed - session_id={session.id} room_id={room_id}") + + # Close all active user sessions + updated_count = LiveSessionUser.objects.filter( + session=session, + is_online=True, + exited_at__isnull=True + ).update( + is_online=False, + exited_at=now, + updated_at=now + ) + + logger.info(f"[PlugNMeet Webhook] User sessions closed - session_id={session.id} count={updated_count}") + + return { + 'message': 'Room finished', + 'session_id': session.id, + 'users_disconnected': updated_count + } + + def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Handle participant_joined event: Create or update LiveSessionUser entry. + + Payload structure: + { + "event": "participant_joined", + "room": { + "sid": "room-123456", + "identity": "course-slug-20240101120000" + }, + "participant": { + "sid": "participant-user-27", + "identity": "27", + "name": "دانشجو نمونه", + "metadata": "{\"is_admin\": false}", + "joinedAt": 1697497300 + } + } + """ + room_data = payload.get('room', {}) + participant_data = payload.get('participant', {}) + + room_id = room_data.get('identity') + user_identity = participant_data.get('identity') + joined_at_timestamp = participant_data.get('joinedAt') + + if not room_id or not user_identity: + logger.warning(f"[PlugNMeet Webhook] participant_joined: Missing room_id or user_identity") + return {'message': 'Missing required fields'} + + logger.info(f"[PlugNMeet Webhook] participant_joined - room_id={room_id} user_identity={user_identity}") + + # Get session + try: + session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) + except CourseLiveSession.DoesNotExist: + logger.warning(f"[PlugNMeet Webhook] participant_joined: Active session not found - room_id={room_id}") + return {'message': 'Active session not found'} + + # Get user + try: + user = User.objects.get(id=int(user_identity)) + except (User.DoesNotExist, ValueError): + logger.warning(f"[PlugNMeet Webhook] participant_joined: User not found - user_identity={user_identity}") + return {'message': 'User not found'} + + # Determine user role + is_admin = user.can_manage_course(session.course) + role = 'moderator' if is_admin else 'participant' + + # Parse joined_at timestamp + if joined_at_timestamp: + entered_at = timezone.datetime.fromtimestamp(joined_at_timestamp, tz=timezone.utc) + else: + entered_at = timezone.now() + + # Check if user has an existing session entry that was marked as offline + # If so, reactivate it instead of creating a new one + existing_session = LiveSessionUser.objects.filter( + session=session, + user=user, + is_online=False + ).order_by('-entered_at').first() + + if existing_session: + # Reactivate existing session + existing_session.is_online = True + existing_session.exited_at = None + existing_session.save(update_fields=['is_online', 'exited_at', 'updated_at']) + logger.info(f"[PlugNMeet Webhook] User rejoined (reactivated) - session_user_id={existing_session.id} user_id={user.id}") + return { + 'message': 'Participant rejoined', + 'session_user_id': existing_session.id, + 'created': False + } + + # Create new LiveSessionUser entry + try: + session_user = LiveSessionUser.objects.create( + session=session, + user=user, + role=role, + entered_at=entered_at, + is_online=True, + ) + logger.info(f"[PlugNMeet Webhook] User joined - session_user_id={session_user.id} user_id={user.id} role={role}") + except Exception as e: + logger.error(f"[PlugNMeet Webhook] Failed to create session user - error={str(e)}") + return {'message': f'Failed to create session user: {str(e)}'} + + return { + 'message': 'Participant joined', + 'session_user_id': session_user.id, + 'created': created + } + + def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Handle participant_left event: Mark LiveSessionUser as offline/exited. + + Payload structure: + { + "event": "participant_left", + "room": { + "sid": "room-123456", + "identity": "course-slug-20240101120000" + }, + "participant": { + "sid": "participant-user-27", + "identity": "27", + "state": "DISCONNECTED", + "joinedAt": 1697497300, + "duration": 1800 + } + } + """ + room_data = payload.get('room', {}) + participant_data = payload.get('participant', {}) + + room_id = room_data.get('identity') + user_identity = participant_data.get('identity') + + if not room_id or not user_identity: + logger.warning(f"[PlugNMeet Webhook] participant_left: Missing room_id or user_identity") + return {'message': 'Missing required fields'} + + logger.info(f"[PlugNMeet Webhook] participant_left - room_id={room_id} user_identity={user_identity}") + + # Get session + try: + session = CourseLiveSession.objects.get(room_id=room_id) + except CourseLiveSession.DoesNotExist: + logger.warning(f"[PlugNMeet Webhook] participant_left: Session not found - room_id={room_id}") + return {'message': 'Session not found'} + + # Get user + try: + user = User.objects.get(id=int(user_identity)) + except (User.DoesNotExist, ValueError): + logger.warning(f"[PlugNMeet Webhook] participant_left: User not found - user_identity={user_identity}") + return {'message': 'User not found'} + + # Update LiveSessionUser + now = timezone.now() + updated_count = LiveSessionUser.objects.filter( + session=session, + user=user, + is_online=True, + exited_at__isnull=True + ).update( + is_online=False, + exited_at=now, + updated_at=now + ) + + if updated_count > 0: + logger.info(f"[PlugNMeet Webhook] User left - session_id={session.id} user_id={user.id}") + else: + logger.warning(f"[PlugNMeet Webhook] participant_left: No active session found for user - session_id={session.id} user_id={user.id}") + + return { + 'message': 'Participant left', + 'updated': updated_count > 0 + } + + def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Handle end_recording event: Download and save recording file. + + Payload structure: + { + "event": "end_recording", + "room": { + "sid": "room-123456", + "identity": "course-slug-20240101120000" + }, + "recording_info": { + "recordingId": "rec-123456", + "roomId": "course-slug-20240101120000", + "recordingType": "COMPOSITE", + "fileName": "algebra-1402-20231016.mp4", + "duration": 3600, + "status": "FINISHED" + } + } + """ + from django.core.files.base import ContentFile + from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError + from apps.course.models import LiveSessionRecording + import os + import tempfile + import subprocess + + room_data = payload.get('room', {}) + recording_info = payload.get('recording_info', {}) + + room_id = room_data.get('identity') + recording_id = recording_info.get('recordingId') + file_name = recording_info.get('fileName', 'recording.mp4') + duration = recording_info.get('duration', 0) + + if not room_id or not recording_id: + logger.warning(f"[PlugNMeet Webhook] end_recording: Missing room_id or recording_id") + return {'message': 'Missing required fields'} + + logger.info(f"[PlugNMeet Webhook] end_recording - room_id={room_id} recording_id={recording_id}") + + # Get session + try: + session = CourseLiveSession.objects.get(room_id=room_id) + except CourseLiveSession.DoesNotExist: + logger.warning(f"[PlugNMeet Webhook] end_recording: Session not found - room_id={room_id}") + return {'message': 'Session not found'} + + try: + client = PlugNMeetClient() + + # Step 1: Get recording info + logger.info(f"[PlugNMeet Webhook] Fetching recording info - recording_id={recording_id}") + recording_data = client.get_recording_info(recording_id) + + if not recording_data.get('status'): + logger.error(f"[PlugNMeet Webhook] Failed to get recording info - recording_id={recording_id}") + return {'message': 'Failed to get recording info', 'error': recording_data.get('msg')} + + # Step 2: Get download token + logger.info(f"[PlugNMeet Webhook] Getting download token - recording_id={recording_id}") + token_response = client.get_recording_download_token(recording_id) + + if not token_response.get('status'): + logger.error(f"[PlugNMeet Webhook] Failed to get download token - recording_id={recording_id}") + return {'message': 'Failed to get download token', 'error': token_response.get('msg')} + + download_token = token_response.get('token') + if not download_token: + logger.error(f"[PlugNMeet Webhook] No download token in response - recording_id={recording_id}") + return {'message': 'No download token received'} + + # Step 3: Download file + download_path = f"/download/recording/{download_token}" + + # Create temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file: + tmp_file_path = tmp_file.name + + try: + logger.info(f"[PlugNMeet Webhook] Downloading recording file - recording_id={recording_id}") + client.download_file(download_path, tmp_file_path) + + # Get file size + file_size = os.path.getsize(tmp_file_path) + logger.info(f"[PlugNMeet Webhook] File downloaded - size={file_size} bytes") + + # Determine recording type (video or audio) + is_video = file_name.lower().endswith(('.mp4', '.webm', '.mkv')) + recording_type = 'video' if is_video else 'voice' + + # Read file content + with open(tmp_file_path, 'rb') as f: + file_content = f.read() + + # Create LiveSessionRecording entry + recording = LiveSessionRecording.objects.create( + session=session, + title=f"{session.subject} - Recording", + file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, + recording_type=recording_type, + ) + + # Save file to Django FileField + recording.file.save(file_name, ContentFile(file_content), save=True) + logger.info(f"[PlugNMeet Webhook] Recording saved - recording_id={recording.id} file={file_name}") + + # Generate thumbnail for video recordings (if ffmpeg is available) + thumbnail_generated = False + if is_video and file_size > 0: + try: + thumbnail_generated = self._generate_video_thumbnail(tmp_file_path, recording) + if thumbnail_generated: + logger.info(f"[PlugNMeet Webhook] Thumbnail generated - recording_id={recording.id}") + except Exception as e: + logger.warning(f"[PlugNMeet Webhook] Thumbnail generation skipped - error={str(e)}") + + return { + 'message': 'Recording downloaded and saved successfully', + 'recording_id': recording.id, + 'file_name': file_name, + 'file_size': file_size, + 'thumbnail_generated': thumbnail_generated + } + + finally: + # Clean up temporary file + if os.path.exists(tmp_file_path): + os.unlink(tmp_file_path) + logger.debug(f"[PlugNMeet Webhook] Temporary file deleted - path={tmp_file_path}") + + except PlugNMeetError as e: + logger.error(f"[PlugNMeet Webhook] PlugNMeet API error - recording_id={recording_id} error={str(e)}") + return {'message': f'PlugNMeet API error: {str(e)}'} + except Exception as e: + logger.error(f"[PlugNMeet Webhook] Unexpected error - recording_id={recording_id} error={str(e)}", exc_info=True) + return {'message': f'Unexpected error: {str(e)}'} + + def _generate_video_thumbnail(self, video_path: str, recording: 'LiveSessionRecording') -> bool: + """ + Generate thumbnail from video file using ffmpeg. + + Args: + video_path: Path to the video file + recording: LiveSessionRecording instance + + Returns: + True if thumbnail generated successfully, False otherwise + """ + from django.core.files.base import ContentFile + import subprocess + import tempfile + import os + + try: + # Create temporary file for thumbnail + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: + thumbnail_path = tmp_thumb.name + + # Extract frame at 1 second using ffmpeg + # -ss 1: seek to 1 second + # -i: input file + # -frames:v 1: extract 1 frame + # -q:v 2: quality (2 is high quality) + cmd = [ + 'ffmpeg', + '-ss', '1', + '-i', video_path, + '-frames:v', '1', + '-q:v', '2', + '-vf', 'scale=640:-1', # scale to width 640, maintain aspect ratio + '-y', # overwrite output file + thumbnail_path + ] + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30 + ) + + if result.returncode != 0: + logger.warning(f"[PlugNMeet Webhook] ffmpeg failed - return_code={result.returncode}") + return False + + # Check if thumbnail was created + if not os.path.exists(thumbnail_path) or os.path.getsize(thumbnail_path) == 0: + logger.warning(f"[PlugNMeet Webhook] Thumbnail file not created or empty") + return False + + # Save thumbnail to recording + with open(thumbnail_path, 'rb') as f: + thumbnail_content = f.read() + + thumbnail_filename = f"thumb_{recording.id}.jpg" + recording.thumbnail.save(thumbnail_filename, ContentFile(thumbnail_content), save=True) + + # Clean up temporary file + os.unlink(thumbnail_path) + + return True + + except subprocess.TimeoutExpired: + logger.error(f"[PlugNMeet Webhook] ffmpeg timeout during thumbnail generation") + return False + except FileNotFoundError: + logger.error(f"[PlugNMeet Webhook] ffmpeg not found - please install ffmpeg") + return False + except Exception as e: + logger.error(f"[PlugNMeet Webhook] Thumbnail generation error - error={str(e)}", exc_info=True) + return False + finally: + # Clean up temporary file if it still exists + if 'thumbnail_path' in locals() and os.path.exists(thumbnail_path): + try: + os.unlink(thumbnail_path) + except: + pass diff --git a/apps/dobodbi_calendar/__init__.py b/apps/dobodbi_calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dobodbi_calendar/admin.py b/apps/dobodbi_calendar/admin.py new file mode 100644 index 0000000..35165fd --- /dev/null +++ b/apps/dobodbi_calendar/admin.py @@ -0,0 +1,135 @@ +import json +from django.contrib import admin +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from dj_language.models import Language +from django import forms +from import_export import fields, widgets, resources +from import_export.admin import ImportExportModelAdmin +from unfold.decorators import display +from unfold.admin import ModelAdmin, TabularInline +from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm +from unfold.contrib.filters.admin import ( + RangeDateFilter, + RangeNumericFilter, + SingleNumericFilter, + ChoicesDropdownFilter +) +from apps.dobodbi_calendar.models import CalendarOccasions +from utils.json_editor_field import JsonEditorWidget +from utils.admin import dovoodi_admin_site +from utils.schema import get_calender_dates_schema + + + + + +class CalendarOccasionsForm(forms.ModelForm): + + class Meta: + model = CalendarOccasions + fields = '__all__' + + widgets = { + 'dates': JsonEditorWidget(attrs={ + 'schema': get_calender_dates_schema(), + 'title': _('Dates'), + }), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + + +class CalendarOccasionsAdmin(ModelAdmin): + form = CalendarOccasionsForm + ordering = ('-id',) + list_display = [ + "title", + "display_occasion_type", + "display_event_type", + "is_global", + "is_yearly", + "display_dates", + ] + + list_filter = [ + ("occasion_type", ChoicesDropdownFilter), + ("event_type", ChoicesDropdownFilter), + "is_global", + "is_yearly", + ("created_at", RangeDateFilter), + ] + + search_fields = [ + "title", + "dates", + ] + + fieldsets = ( + ("Basic Information", { + "fields": ( + "title", + "is_global", + "is_yearly", + "occasion_type", + "event_type", + ), + "description": "Main information about the calendar occasion", + }), + ("Dates Configuration", { + "fields": ("dates",), + "classes": ("collapse",), + "description": "Configure dates for this occasion", + }), + ("Metadata", { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + "description": "Metadata information", + }), + ) + readonly_fields = ["created_at", "updated_at"] + + # سفارشی‌سازی اکشن‌ها + actions = ["make_global", "make_not_global"] + + # متدهای اکشن + @admin.action(description="Mark selected occasions as global") + def make_global(self, request, queryset): + queryset.update(is_global=True) + + @admin.action(description="Mark selected occasions as not global") + def make_not_global(self, request, queryset): + queryset.update(is_global=False) + + + @display(description="Occasion Type", label=True) + def display_occasion_type(self, obj): + return obj.get_occasion_type_display() + + @display(description="Event Type", label=True) + def display_event_type(self, obj): + return obj.get_event_type_display() + + @display(description="Dates") + def display_dates(self, obj): + from django.utils.html import format_html + return "\n".join([f"{i['month']}/{i['day']}" for i in obj.dates]) + + + def get_search_results(self, request, queryset, search_term): + queryset, use_distinct = super().get_search_results(request, queryset, search_term) + try: + # امکان جستجو در فیلد JSON + import json + json.loads(search_term) + queryset |= self.model.objects.filter(dates__contains=search_term) + except ValueError: + pass + return queryset, use_distinct + + +dovoodi_admin_site.register(CalendarOccasions, CalendarOccasionsAdmin) diff --git a/apps/dobodbi_calendar/admin/calendar.html b/apps/dobodbi_calendar/admin/calendar.html new file mode 100644 index 0000000..8f5f4df --- /dev/null +++ b/apps/dobodbi_calendar/admin/calendar.html @@ -0,0 +1,63 @@ +{% extends 'admin/change_form.html' %} +{% block scripts %} + {{ block.super }} + + + +{% endblock %} \ No newline at end of file diff --git a/apps/dobodbi_calendar/admin/json_date_field.html b/apps/dobodbi_calendar/admin/json_date_field.html new file mode 100644 index 0000000..b86766c --- /dev/null +++ b/apps/dobodbi_calendar/admin/json_date_field.html @@ -0,0 +1,2 @@ + diff --git a/apps/dobodbi_calendar/apps.py b/apps/dobodbi_calendar/apps.py new file mode 100644 index 0000000..0d62830 --- /dev/null +++ b/apps/dobodbi_calendar/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DobodbiCalendarConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.dobodbi_calendar' diff --git a/apps/dobodbi_calendar/migrations/0001_initial.py b/apps/dobodbi_calendar/migrations/0001_initial.py new file mode 100644 index 0000000..197753b --- /dev/null +++ b/apps/dobodbi_calendar/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.8 on 2025-05-04 08:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CalendarOccasions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('is_global', models.BooleanField(default=False, help_text='check this field if event is global', verbose_name='is global')), + ('occasion_type', models.CharField(choices=[('georgian', 'georgian'), ('lunar', 'lunar')], default='georgian', help_text='Choose between georgian or lunar. default to georgian', max_length=12, verbose_name='occasion type')), + ('dates', models.JSONField(verbose_name='dates')), + ('is_yearly', models.BooleanField(default=True, help_text='check this field if event is annually', verbose_name='is yearly')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('event_type', models.CharField(choices=[('national', 'National'), ('international', 'International'), ('religious', 'Religious')], max_length=16, null=True, verbose_name='event type')), + ], + options={ + 'ordering': ('-updated_at',), + }, + ), + ] diff --git a/apps/dobodbi_calendar/migrations/__init__.py b/apps/dobodbi_calendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dobodbi_calendar/models.py b/apps/dobodbi_calendar/models.py new file mode 100644 index 0000000..e241725 --- /dev/null +++ b/apps/dobodbi_calendar/models.py @@ -0,0 +1,48 @@ +from django.db import models + +from django.utils.translation import gettext_lazy as _ + + +class CalendarOccasions(models.Model): + """ + calendar events model + """ + + class OccasionType(models.TextChoices): + GEORGIAN = 'georgian', _('georgian') + LUNAR = 'lunar', _('lunar') + + class EventType(models.TextChoices): + national = 'national', _('National') + international = 'international', _('International') + religious = 'religious', _('Religious') + + title = models.CharField(_("title"), max_length=255) + is_global = models.BooleanField( + verbose_name=_('is global'), default=False, + help_text=_('check this field if event is global'), + ) + + occasion_type = models.CharField( + choices=OccasionType.choices, + default=OccasionType.GEORGIAN, + max_length=12, + help_text=_('Choose between georgian or lunar. default to georgian'), + verbose_name=_('occasion type') + ) + dates = models.JSONField(verbose_name=_('dates')) + is_yearly = models.BooleanField( + verbose_name=_('is yearly'), default=True, + help_text=_('check this field if event is annually') + ) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + event_type = models.CharField(max_length=16, choices=EventType.choices, null=True, verbose_name=_('event type')) + + class Meta: + ordering = ('-updated_at',) + + def __str__(self) -> str: + return self.title + + \ No newline at end of file diff --git a/apps/dobodbi_calendar/serializer.py b/apps/dobodbi_calendar/serializer.py new file mode 100644 index 0000000..2a65c4d --- /dev/null +++ b/apps/dobodbi_calendar/serializer.py @@ -0,0 +1,34 @@ +from rest_framework import serializers + +from apps.dobodbi_calendar.models import CalendarOccasions + + +class CalendarSerializer(serializers.ModelSerializer): + type = serializers.CharField(source='occasion_type') + dates = serializers.SerializerMethodField() + + + # def get_countries(self, obj): + # if not obj.countries or obj.countries[0] == 'ALL': + # return ["All"] + + # return [country.name or country.code for country in obj.countries] + + # def get_holiday_in_countries(self, obj): + # return [country.name or country.code for country in obj.holiday_in_countries] + + def get_dates(self, obj): + dates = [] + for date in obj.dates: + dates.append({ + 'day': str(date['day']), + 'month': str(date['month']), + 'year': str(date.get('year', '')), + }) + return dates + + + class Meta: + model = CalendarOccasions + fields = ('id', 'title', 'type', 'event_type', 'dates', 'is_yearly',) + diff --git a/apps/dobodbi_calendar/tests.py b/apps/dobodbi_calendar/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/dobodbi_calendar/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/dobodbi_calendar/urls.py b/apps/dobodbi_calendar/urls.py new file mode 100644 index 0000000..57dfc8c --- /dev/null +++ b/apps/dobodbi_calendar/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from apps.dobodbi_calendar.views import CalendarList, AdjustmentConfigView + + +urlpatterns = [ + path('occasions/', CalendarList.as_view()), + path('adjustemnts/', AdjustmentConfigView.as_view()), + +] diff --git a/apps/dobodbi_calendar/views.py b/apps/dobodbi_calendar/views.py new file mode 100644 index 0000000..df97681 --- /dev/null +++ b/apps/dobodbi_calendar/views.py @@ -0,0 +1,97 @@ +from django.shortcuts import render + +import datetime +import json +from collections import OrderedDict + +from django.db.models import Q +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from rest_framework.generics import ListAPIView +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView, status +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.account.models import User +from apps.dobodbi_calendar.models import CalendarOccasions +from apps.dobodbi_calendar.serializer import CalendarSerializer +from utils.config_getter import get_config + + +class CalendarList(ListAPIView): + serializer_class = CalendarSerializer + pagination_class = None + + permission_classes = (AllowAny,) + + @swagger_auto_schema( + operation_description="Get list of calendar occasions", + tags=["Dobodbi - Calendar"], + manual_parameters=[ + openapi.Parameter( + 'last_updated', openapi.IN_QUERY, + description="Filter occasions updated after this timestamp", + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + 200: openapi.Response( + description="List of calendar occasions with metadata" + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + # @method_decorator(cache_page(60 * 15)) # Cache for 1 Hour + # def dispatch(self, *args, **kwargs): + # return super().dispatch(*args, **kwargs) + + def get_queryset(self): + queryset = CalendarOccasions.objects.all() + req = self.request + + if v := req.query_params.get('last_updated'): + query &= Q( + updated_at__gte=v + ) + + return queryset + + + def list(self, request, *args, **kwargs): + q = self.get_queryset() + last_item_date = q.first() + if last_item_date: + last_updated = last_item_date.updated_at + datetime.timedelta(microseconds=1) + last_updated = str(last_updated) + else: + last_updated = None + + d = self.get_serializer(q, many=True).data + data = OrderedDict({ + 'last_updated': last_updated, + 'total': len(d), + 'data': d, + }) + return Response(data) + + +class AdjustmentConfigView(APIView): + permission_classes = (AllowAny,) + + @swagger_auto_schema( + operation_description="Get calendar adjustment configuration", + tags=["Dobodbi - Calendar"], + responses={ + 200: openapi.Response( + description="Calendar adjustment configuration" + ) + } + ) + def get(self, request): + adjustment_config = get_config('calendar__Adjustment') + return Response(json.loads(adjustment_config)) diff --git a/apps/hadis/admin/__init__.py b/apps/hadis/admin/__init__.py index ba8ce70..7074a60 100644 --- a/apps/hadis/admin/__init__.py +++ b/apps/hadis/admin/__init__.py @@ -1,3 +1,9 @@ from .category import * from .hadis import * -from .transmitter import * \ No newline at end of file +<<<<<<< HEAD +from .transmitter import * +======= +from .transmitter import * +from .reference import * +from .version import * +>>>>>>> develop diff --git a/apps/hadis/admin/category.py b/apps/hadis/admin/category.py index 4ffcefa..656bc8d 100644 --- a/apps/hadis/admin/category.py +++ b/apps/hadis/admin/category.py @@ -1,4 +1,5 @@ from django.contrib import admin +<<<<<<< HEAD from django.utils.translation import gettext_lazy as _ from dj_category.admin import BaseCategoryAdmin from ajaxdatatable.admin import AjaxDatatable @@ -215,3 +216,225 @@ class HadisCategoryAdmin(BaseCategoryAdmin): +======= +from django import forms +from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html +from unfold.admin import ModelAdmin +from unfold.decorators import display, action +from mptt.admin import DraggableMPTTAdmin +from utils.json_editor_field import JsonEditorWidget +import json + +from utils.admin import dovoodi_admin_site +from ..models import HadisSect, HadisCategory + + +# Custom Forms for JSON Fields +class HadisSectAdminForm(forms.ModelForm): + """Custom form for HadisSect with JSON editor widgets""" + + class Meta: + model = HadisSect + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for description JSON field + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) + + +class HadisCategoryAdminForm(forms.ModelForm): + """Custom form for HadisCategory with JSON editor widgets""" + + class Meta: + model = HadisCategory + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for description JSON field + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) + + +class HadisSectAdmin(ModelAdmin): + """Admin for HadisSect model""" + form = HadisSectAdminForm + list_display = ('sect_type', 'display_title', 'is_active', 'order') + list_filter = ('sect_type', 'is_active') + search_fields = ('title',) + ordering = ('order',) + + fieldsets = ( + (None, { + 'fields': ('sect_type', 'title', 'is_active', 'order','description') + }), + ) + + def display_title(self, obj): + """Extracts text from the title JSON list""" + try: + return obj.title[0]['text'] + except (IndexError, KeyError, TypeError, AttributeError): + return "No Title" + + display_title.short_description = _('Title') + + +class HadisCategoryAdmin(DraggableMPTTAdmin, ModelAdmin): + """Admin for HadisCategory model with MPTT tree support""" + form = HadisCategoryAdminForm + list_display = ('indented_title', 'sect', 'source_type', 'order') + list_display_links = ('indented_title',) + list_filter = ('sect', 'source_type') + search_fields = ('title',) + ordering = ('tree_id', 'lft') + + + fieldsets = ( + (None, { + 'fields': ('parent', 'sect', 'source_type', 'title', 'order','description') + }), + (_('Files'), { + 'fields': ('xmind_file',), + 'classes': ('collapse',) + }), + ) + + def indented_title(self, instance): + """Display indented title for tree structure using JSON text""" + try: + # Extract text from the first element of the title list + title_text = instance.title[0]['text'] + except (IndexError, KeyError, TypeError, AttributeError): + title_text = "No Title" + + # DraggableMPTTAdmin works best if you don't mess with the HTML too much, + # but here is your requested dash indentation style combined with clean text: + return f"{'—' * instance.level} {title_text}" + + indented_title.short_description = _('Title') + + +# Register models with the custom admin site +dovoodi_admin_site.register(HadisSect, HadisSectAdmin) +dovoodi_admin_site.register(HadisCategory, HadisCategoryAdmin) +>>>>>>> develop diff --git a/apps/hadis/admin/hadis.py b/apps/hadis/admin/hadis.py index 444918c..977e19d 100644 --- a/apps/hadis/admin/hadis.py +++ b/apps/hadis/admin/hadis.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from django.contrib import admin from django.utils.translation import gettext_lazy as _ from dj_category.admin import BaseCategoryAdmin @@ -112,10 +113,134 @@ class HadisOverviewAdmin(AjaxDatatable): (_('Additional Information'), { 'fields': ('links', 'tags', 'created_at'), 'classes': ('collapse',), +======= +from django import forms +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin, TabularInline +from unfold.contrib.forms.widgets import WysiwygWidget +from unfold.decorators import display, action +from utils.json_editor_field import JsonEditorWidget +import json + +from utils.admin import dovoodi_admin_site,dovoodi_admin_site +from ..models import ( + Hadis, HadisReference, HadisTag, HadisStatus, ReferenceImage, + HadisCollection, HadisInCollection, HadisCorrection +) + + +# Custom Forms for JSON Fields +class HadisAdminForm(forms.ModelForm): + """Custom form for Hadis with JSON editor widgets""" + + class Meta: + model = Hadis + fields = '__all__' + widgets = { + 'explanation': WysiwygWidget(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for translation JSON field + translation_schema = { + "type": "array", + "title": "Translations", + "items": { + "type": "object", + "title": "Translation", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu"] + } + }, + "text": { # <‑‑ use text, not title + "type": "string", + "title": "Translation Text" + } + }, + "required": ["language_code", "text"] # <‑‑ update required key + } + } + + # Schema for links JSON field (array of objects with title and link) + links_schema = { + "type": "array", + "title": "Links", + "items": { + "type": "object", + "title": "Link", + "properties": { + "title": { + "type": "string", + "title": "Link Title" + }, + "link": { + "type": "string", + "title": "URL", + "format": "uri" + } + }, + "required": ["title", "link"] + } + } + + # Apply JSON editor widgets + self.fields['translation'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(translation_schema), + 'title': 'Translations' + }) + + self.fields['links'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(links_schema), + 'title': 'Links' + }) + + +# Inline Admin Classes +class ReferenceImageInline(TabularInline): + """Inline for ReferenceImage in HadisReference admin""" + model = ReferenceImage + extra = 0 + fields = ('thumbnail', 'priority') + ordering = ('priority',) + + +class HadisReferenceInline(TabularInline): + """Inline for HadisReference in Hadis admin""" + model = HadisReference + extra = 0 + fields = ('book_reference',) + readonly_fields = ('created_at',) + + +# Main Admin Classes +class HadisTagAdmin(ModelAdmin): + """Admin for HadisTag model""" + list_display = ('title', 'status', 'created_at') + list_filter = ('status', 'created_at') + search_fields = ('title',) + readonly_fields = ('created_at', 'updated_at') + + fieldsets = ( + (None, { + 'fields': ('title', 'status') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) +>>>>>>> develop }), ) +<<<<<<< HEAD class HadisOverviewInline(admin.StackedInline): change_form_template = 'admin/hadisowerview_change_form.html' form = HadisOverviewForm @@ -148,10 +273,55 @@ class HadisAdmin(AjaxDatatable): (_('Content'), { 'fields': ('text', 'translation'), 'classes': ('collapse',), +======= +class HadisStatusAdmin(ModelAdmin): + """Admin for HadisStatus model""" + list_display = ('title', 'color', 'order') + list_filter = ('color',) + search_fields = ('title',) + ordering = ('order',) + + fieldsets = ( + (None, { + 'fields': ('title', 'color', 'order') + }), + ) + + +class HadisAdmin(ModelAdmin): + """Admin for Hadis model""" + form = HadisAdminForm + list_display = ('number', 'title', 'category', 'status', 'hadis_status', 'created_at') + list_filter = ('status', 'hadis_status', 'category', 'created_at') + search_fields = ('title', 'text', 'category__title') + readonly_fields = ('created_at', 'updated_at', 'share_link') + ordering = ('category', 'number') + inlines = [HadisReferenceInline] + filter_horizontal = ('tags',) + + fieldsets = ( + (None, { + 'fields': ('category', 'number', 'title', 'status') + }), + (_('Content'), { + 'fields': ('text', 'translation', 'explanation') + }), + (_('Status & Classification'), { + 'fields': ('hadis_status', 'hadis_status_text', 'tags') + }), + (_('Additional Information'), { + 'fields': ('address', 'links', 'share_link'), + 'classes': ('collapse',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) +>>>>>>> develop }), ) +<<<<<<< HEAD def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) if obj is None: @@ -159,3 +329,223 @@ class HadisAdmin(AjaxDatatable): return form +======= +class HadisReferenceAdmin(ModelAdmin): + """Admin for HadisReference model""" + list_display = ('hadis', 'book_reference', 'created_at') + list_filter = ('created_at', 'book_reference') + search_fields = ('hadis__title', 'book_reference__title') + readonly_fields = ('created_at',) + inlines = [ReferenceImageInline] + + fieldsets = ( + (None, { + 'fields': ('hadis', 'book_reference') + }), + (_('Timestamps'), { + 'fields': ('created_at',), + 'classes': ('collapse',) + }), + ) + + +class ReferenceImageAdmin(ModelAdmin): + """Admin for ReferenceImage model""" + list_display = ('reference', 'thumbnail', 'priority') + list_filter = ('priority',) + search_fields = ('reference__hadis__title', 'reference__book__title') + ordering = ('reference', 'priority') + + fieldsets = ( + (None, { + 'fields': ('reference', 'thumbnail', 'priority') + }), + ) + + +class HadisInCollectionInline(TabularInline): + """Inline for HadisInCollection in HadisCollection admin""" + model = HadisInCollection + extra = 0 + fields = ('hadis', 'order') + ordering = ('order',) + + +class HadisCollectionAdmin(ModelAdmin): + """Admin for HadisCollection model""" + list_display = ('title', 'slug', 'status', 'order', 'created_at') + list_filter = ('status', 'created_at') + search_fields = ('title', 'slug', 'summary') + readonly_fields = ('slug', 'created_at', 'updated_at') + ordering = ('order',) + inlines = [HadisInCollectionInline] + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'summary', 'status', 'order', 'thumbnail') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +class HadisInCollectionAdmin(ModelAdmin): + """Admin for HadisInCollection model""" + list_display = ('hadis', 'collection', 'order', 'created_at') + list_filter = ('collection', 'created_at') + search_fields = ('hadis__title', 'collection__title') + readonly_fields = ('created_at',) + ordering = ('collection', 'order') + + fieldsets = ( + (None, { + 'fields': ('hadis', 'collection', 'order') + }), + (_('Timestamps'), { + 'fields': ('created_at',), + 'classes': ('collapse',) + }), + ) + + +class HadisCorrectionAdminForm(forms.ModelForm): + """Custom form for HadisCorrection with JSON editor widgets""" + + class Meta: + model = HadisCorrection + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for description JSON field + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for translation JSON field + translation_schema = { + "type": "array", + "title": "Translations", + "items": { + "type": "object", + "title": "Translation", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Translation Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) + + self.fields['translation'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(translation_schema), + 'title': 'Translations' + }) + + +class HadisCorrectionAdmin(ModelAdmin): + """Admin for HadisCorrection model""" + form = HadisCorrectionAdminForm + list_display = ('hadis', 'title', 'slug', 'created_at') + list_filter = ('created_at', 'hadis__category') + search_fields = ('hadis__title', 'title', 'slug') + readonly_fields = ('slug', 'created_at', 'updated_at', 'share_link') + ordering = ('-created_at',) + + fieldsets = ( + (None, { + 'fields': ('hadis', 'title', 'slug') + }), + (_('Content'), { + 'fields': ('description', 'translation') + }), + (_('Additional Information'), { + 'fields': ('share_link',), + 'classes': ('collapse',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +# Register models with dovoodi admin site +dovoodi_admin_site.register(HadisTag, HadisTagAdmin) +dovoodi_admin_site.register(HadisStatus, HadisStatusAdmin) +dovoodi_admin_site.register(Hadis, HadisAdmin) +dovoodi_admin_site.register(HadisReference, HadisReferenceAdmin) +dovoodi_admin_site.register(ReferenceImage, ReferenceImageAdmin) +dovoodi_admin_site.register(HadisCollection, HadisCollectionAdmin) +dovoodi_admin_site.register(HadisInCollection, HadisInCollectionAdmin) +dovoodi_admin_site.register(HadisCorrection, HadisCorrectionAdmin) +>>>>>>> develop diff --git a/apps/hadis/admin/reference.py b/apps/hadis/admin/reference.py new file mode 100644 index 0000000..ef6f2db --- /dev/null +++ b/apps/hadis/admin/reference.py @@ -0,0 +1,389 @@ +from django.contrib import admin +from django import forms +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin, TabularInline +from unfold.decorators import display +from utils.json_editor_field import JsonEditorWidget +import json + +# Import your custom admin site +from utils.admin import dovoodi_admin_site + +# Import your models +from ..models import ( + BookReference, + BookReferenceImage, + BookAuthor, + BookAttribute +) + +# ----------------------------------------------------------------------------- +# Custom Forms for JSON Fields +# ----------------------------------------------------------------------------- + +class BookReferenceAdminForm(forms.ModelForm): + """Custom form for BookReference with JSON editor widgets""" + + class Meta: + model = BookReference + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for description JSON field + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for language JSON field + language_schema = { + "type": "array", + "title": "Languages", + "items": { + "type": "object", + "title": "Language", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Language Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for publisher JSON field + publisher_schema = { + "type": "array", + "title": "Publishers", + "items": { + "type": "object", + "title": "Publisher", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Publisher Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) + + self.fields['language'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(language_schema), + 'title': 'Languages' + }) + + self.fields['publisher'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(publisher_schema), + 'title': 'Publishers' + }) + + +class BookAttributeAdminForm(forms.ModelForm): + """Custom form for BookAttribute with JSON editor widgets""" + + class Meta: + model = BookAttribute + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for value JSON field + value_schema = { + "type": "array", + "title": "Values", + "items": { + "type": "object", + "title": "Value", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Value Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + self.fields['value'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(value_schema), + 'title': 'Values' + }) + + +# ----------------------------------------------------------------------------- +# 1. Inlines +# ----------------------------------------------------------------------------- + +class BookReferenceImageInline(TabularInline): + """ + Inline for managing Book Images directly inside the BookReference page. + """ + model = BookReferenceImage + extra = 0 + fields = ('image', 'order', 'description') + readonly_fields = ('created_at',) + + +class BookAttributeInline(TabularInline): + """ + Inline for managing Book Attributes (Key-Value pairs) inside BookReference. + """ + model = BookAttribute + extra = 0 + fields = ('title', 'value') + readonly_fields = ('created_at',) + + +# ----------------------------------------------------------------------------- +# 2. Main Admins +# ----------------------------------------------------------------------------- + +class BookReferenceAdmin(ModelAdmin): + """Admin for BookReference model""" + form = BookReferenceAdminForm + + # Use custom methods for JSON fields to show readable text + list_display = ( + 'get_title_display', + 'slug', + 'get_publisher_display', + 'year_of_publication', + 'rate', + 'created_at' + ) + + list_filter = ('year_of_publication', 'rate', 'created_at') + + # Searching by JSON fields via string is limited in Django, + # so we focus on standard fields + search_fields = ('isbn', 'slug', 'volume') + + readonly_fields = ('created_at', 'updated_at') + + # Add the inlines to manage images and attributes on the same page + inlines = [BookReferenceImageInline, BookAttributeInline] + + fieldsets = ( + (_('Basic Info'), { + 'fields': ('title', 'description', 'slug', 'language') + }), + (_('Publication Info'), { + 'fields': ('publisher', 'isbn', 'year_of_publication', 'number_page', 'volume') + }), + (_('Rating & Stats'), { + 'fields': ('rate', 'created_at', 'updated_at') + }), + ) + + # --- Custom Display Methods --- + + @display(description=_('Title'), ordering='title') + def get_title_display(self, obj): + return self._extract_first_text(obj.title) + + @display(description=_('Publisher')) + def get_publisher_display(self, obj): + return self._extract_first_text(obj.publisher) + + def _extract_first_text(self, json_data): + """Helper to safely extract the first 'text' from a JSON list""" + if json_data and isinstance(json_data, list) and len(json_data) > 0: + first_item = json_data[0] + if isinstance(first_item, dict): + return first_item.get('text', '-') + return '-' + + +class BookAuthorAdmin(ModelAdmin): + """Admin for BookAuthor model""" + + list_display = ('get_name_display', 'created_at', 'updated_at') + search_fields = ('name',) # Note: Search works best on exact text matches + readonly_fields = ('created_at', 'updated_at') + + # Use filter_horizontal for ManyToMany fields to make selection easier + filter_horizontal = ('book_references',) + + @display(description=_('Name'), ordering='name') + def get_name_display(self, obj): + if obj.name and isinstance(obj.name, list) and len(obj.name) > 0: + first = obj.name[0] + if isinstance(first, dict): + return first.get('text', '-') + return '-' + +class BookReferenceImageAdmin(ModelAdmin): + # Display the custom string, plus the raw order and book link for convenience + list_display = ("display_name", "order", "book_reference") + + # optimize database queries since we are accessing foreign key data (book_reference) + list_select_related = ("book_reference",) + + def display_name(self, obj): + # Implements: f"{self.book_reference.title[0]['text']} - Image {self.order}" + try: + # We use safe navigation to prevent admin crashes if data is missing + book_title = obj.book_reference.title[0]['text'] + return f"{book_title} - Image {obj.order}" + except (AttributeError, IndexError, KeyError, TypeError): + # Fallback if the title structure isn't exactly as expected + return f"Unknown Book - Image {obj.order}" + + # Sets the column header name in the admin panel + display_name.short_description = "Image Reference" + +class BookAttributeAdmin(ModelAdmin): + """ + Admin for managing Attributes independently. + Useful if you want to see all attributes across all books. + """ + form = BookAttributeAdminForm + list_display = ('get_title_display', 'get_value_display', 'get_book_display', 'created_at') + list_filter = ('created_at',) + search_fields = ('book_reference__slug',) + + @display(description=_('Title'), ordering='title') + def get_title_display(self, obj): + return self._extract_first_text(obj.title) + + @display(description=_('Value'), ordering='value') + def get_value_display(self, obj): + return self._extract_first_text(obj.value) + + @display(description=_('Book Reference'), ordering='book_reference') + def get_book_display(self, obj): + if obj.book_reference: + return self._extract_first_text(obj.book_reference.title) + return '-' + + def _extract_first_text(self, json_data): + if json_data and isinstance(json_data, list) and len(json_data) > 0: + first = json_data[0] + if isinstance(first, dict): + return first.get('text', '-') + return '-' + + +# ----------------------------------------------------------------------------- +# 3. Registration +# ----------------------------------------------------------------------------- + +dovoodi_admin_site.register(BookReference, BookReferenceAdmin) +dovoodi_admin_site.register(BookAuthor, BookAuthorAdmin) +dovoodi_admin_site.register(BookAttribute, BookAttributeAdmin) +dovoodi_admin_site.register(BookReferenceImage, BookReferenceImageAdmin) \ No newline at end of file diff --git a/apps/hadis/admin/transmitter.py b/apps/hadis/admin/transmitter.py index e69de29..05bcee4 100644 --- a/apps/hadis/admin/transmitter.py +++ b/apps/hadis/admin/transmitter.py @@ -0,0 +1,531 @@ +from django.contrib import admin +from django import forms +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin, TabularInline +from unfold.decorators import display, action +from unfold.contrib.forms.widgets import WysiwygWidget +from utils.json_editor_field import JsonEditorWidget +import json + +from utils.admin import dovoodi_admin_site +from ..models import ( + Transmitters, HadisTransmitter, NarratorLayer, TransmitterReliability, + OpinionStatus, TransmitterOpinion, TransmitterOriginalText +) + +class HadisTransmitterInline(TabularInline): + """Inline for HadisTransmitter in Transmitters admin""" + model = HadisTransmitter + extra = 0 + fields = ('hadis', 'order') + + +class TransmitterOpinionInline(TabularInline): + """Inline for TransmitterOpinion in Transmitters admin""" + model = TransmitterOpinion + extra = 0 + fields = ('scholar_name', 'status') + + +class TransmitterOriginalTextInline(TabularInline): + """Inline for TransmitterOriginalText in Transmitters admin""" + model = TransmitterOriginalText + extra = 0 + fields = ('title', 'slug') + + +class TransmittersAdmin(ModelAdmin): + """Admin for Transmitters model""" + list_display = ('get_full_name_display', 'birth_year_hijri', 'death_year_hijri') + list_filter = ('birth_year_hijri', 'death_year_hijri') + search_fields = ('full_name', 'description') + readonly_fields = ('created_at', 'updated_at') + inlines = [HadisTransmitterInline, TransmitterOpinionInline, TransmitterOriginalTextInline] + + fieldsets = ( + (None, { + 'fields': ('full_name', 'birth_year_hijri', 'death_year_hijri') + }), + (_('Additional Information'), { + 'fields': ('description',), + 'classes': ('collapse',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + @display(description=_('Full Name'), ordering='full_name') + def get_full_name_display(self, obj): + """ + Parses the JSON full_name and returns the first item's text. + """ + if obj.full_name and isinstance(obj.full_name, list) and len(obj.full_name) > 0: + # Safely get the first item + first_item = obj.full_name[0] + if isinstance(first_item, dict): + return first_item.get('text', '-') + return '-' + + +class HadisTransmitterAdmin(ModelAdmin): + """Admin for HadisTransmitter model""" + list_display = ('hadis', 'transmitter', 'order', 'created_at') + list_filter = ( 'created_at',) + search_fields = ('hadis__title', 'transmitter__full_name') + readonly_fields = ('created_at',) + ordering = ('hadis', 'order') + + fieldsets = ( + (None, { + 'fields': ('hadis', 'transmitter', 'order') + }), + (_('Timestamps'), { + 'fields': ('created_at',), + 'classes': ('collapse',) + }), + ) + + +# Custom Forms for JSON Fields +class NarratorLayerAdminForm(forms.ModelForm): + """Custom form for NarratorLayer with JSON editor widgets""" + + class Meta: + model = NarratorLayer + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for name JSON field + name_schema = { + "type": "array", + "title": "Names", + "items": { + "type": "object", + "title": "Name", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Name Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for description JSON field + description_schema = { + "type": "array", + "title": "Descriptions", + "items": { + "type": "object", + "title": "Description", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Description Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['name'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(name_schema), + 'title': 'Names' + }) + + self.fields['description'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(description_schema), + 'title': 'Descriptions' + }) + + +class TransmitterReliabilityAdminForm(forms.ModelForm): + """Custom form for TransmitterReliability with JSON editor widgets""" + + class Meta: + model = TransmitterReliability + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + +class OpinionStatusAdminForm(forms.ModelForm): + """Custom form for OpinionStatus with JSON editor widgets""" + + class Meta: + model = OpinionStatus + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + +class TransmitterOpinionAdminForm(forms.ModelForm): + """Custom form for TransmitterOpinion with JSON editor widgets""" + + class Meta: + model = TransmitterOpinion + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for scholar_name JSON field + scholar_name_schema = { + "type": "array", + "title": "Scholar Names", + "items": { + "type": "object", + "title": "Scholar Name", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Scholar Name Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for opinion_text JSON field + opinion_text_schema = { + "type": "array", + "title": "Opinion Texts", + "items": { + "type": "object", + "title": "Opinion Text", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Opinion Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['scholar_name'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(scholar_name_schema), + 'title': 'Scholar Names' + }) + + self.fields['opinion_text'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(opinion_text_schema), + 'title': 'Opinion Texts' + }) + + +class TransmitterOriginalTextAdminForm(forms.ModelForm): + """Custom form for TransmitterOriginalText with JSON editor widgets""" + + class Meta: + model = TransmitterOriginalText + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Schema for title JSON field + title_schema = { + "type": "array", + "title": "Titles", + "items": { + "type": "object", + "title": "Title", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Title Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for text JSON field + text_schema = { + "type": "array", + "title": "Texts", + "items": { + "type": "object", + "title": "Text", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Text Content" + } + }, + "required": ["language_code", "text"] + } + } + + # Schema for translation JSON field + translation_schema = { + "type": "array", + "title": "Translations", + "items": { + "type": "object", + "title": "Translation", + "properties": { + "language_code": { + "type": "string", + "title": "Language Code", + "enum": ["en", "fa", "ar", "ur", "ru"], + "options": { + "enum_titles": ["English", "Persian", "Arabic", "Urdu", "Russian"] + } + }, + "text": { + "type": "string", + "title": "Translation Text" + } + }, + "required": ["language_code", "text"] + } + } + + # Apply JSON editor widgets + self.fields['title'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(title_schema), + 'title': 'Titles' + }) + + self.fields['text'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(text_schema), + 'title': 'Texts' + }) + + self.fields['translation'].widget = JsonEditorWidget(attrs={ + 'schema': json.dumps(translation_schema), + 'title': 'Translations' + }) + + +# Main Admin Classes +class NarratorLayerAdmin(ModelAdmin): + """Admin for NarratorLayer model""" + form = NarratorLayerAdminForm + list_display = ('number', 'name', 'slug', 'created_at') + list_filter = ('created_at', 'updated_at') + search_fields = ('name', 'slug') + readonly_fields = ('slug', 'created_at', 'updated_at') + ordering = ('number',) + + fieldsets = ( + (None, { + 'fields': ('number', 'name', 'slug') + }), + (_('Content'), { + 'fields': ('description',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +class TransmitterReliabilityAdmin(ModelAdmin): + """Admin for TransmitterReliability model""" + form = TransmitterReliabilityAdminForm + list_display = ('title', 'slug', 'color') + list_filter = ('color',) + search_fields = ('title', 'slug') + readonly_fields = ('slug',) + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'color') + }), + ) + + +class OpinionStatusAdmin(ModelAdmin): + """Admin for OpinionStatus model""" + form = OpinionStatusAdminForm + list_display = ('title', 'slug', 'color') + list_filter = ('color',) + search_fields = ('title', 'slug') + readonly_fields = ('slug',) + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'color') + }), + ) + + + + +class TransmitterOpinionAdmin(ModelAdmin): + """Admin for TransmitterOpinion model""" + form = TransmitterOpinionAdminForm + list_display = ('transmitter', 'scholar_name', 'status', 'created_at') + list_filter = ('status', 'created_at', 'transmitter') + search_fields = ('transmitter__full_name', 'scholar_name') + readonly_fields = ('created_at', 'updated_at') + + fieldsets = ( + (None, { + 'fields': ('transmitter', 'scholar_name') + }), + (_('Content'), { + 'fields': ('opinion_text', 'status') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + + + + +class TransmitterOriginalTextAdmin(ModelAdmin): + """Admin for TransmitterOriginalText model""" + form = TransmitterOriginalTextAdminForm + list_display = ('transmitter', 'title', 'slug') + list_filter = ('transmitter',) + search_fields = ('transmitter__full_name', 'title', 'slug') + readonly_fields = ('slug',) + + fieldsets = ( + (None, { + 'fields': ('transmitter', 'title', 'slug') + }), + (_('Content'), { + 'fields': ('text', 'translation') + }), + (_('Additional Information'), { + 'fields': ('share_link',), + 'classes': ('collapse',) + }), + ) + + +# Register models with the custom admin site +dovoodi_admin_site.register(Transmitters, TransmittersAdmin) +dovoodi_admin_site.register(HadisTransmitter, HadisTransmitterAdmin) +dovoodi_admin_site.register(NarratorLayer, NarratorLayerAdmin) +dovoodi_admin_site.register(TransmitterReliability, TransmitterReliabilityAdmin) +dovoodi_admin_site.register(OpinionStatus, OpinionStatusAdmin) +dovoodi_admin_site.register(TransmitterOpinion, TransmitterOpinionAdmin) +dovoodi_admin_site.register(TransmitterOriginalText, TransmitterOriginalTextAdmin) \ No newline at end of file diff --git a/apps/hadis/admin/version.py b/apps/hadis/admin/version.py new file mode 100644 index 0000000..270892b --- /dev/null +++ b/apps/hadis/admin/version.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin + +from utils.admin import dovoodi_admin_site +from ..models import ContentRelease + + +class ContentReleaseAdmin(ModelAdmin): + """Admin for ContentRelease model""" + list_display = ('version_name', 'published_at', 'is_active') + list_filter = ('is_active', 'published_at') + search_fields = ('version_name', 'description') + readonly_fields = ('published_at',) + ordering = ('-published_at',) + + fieldsets = ( + (None, { + 'fields': ('version_name', 'description', 'is_active') + }), + (_('Timestamps'), { + 'fields': ('published_at',), + 'classes': ('collapse',) + }), + ) + + +# Register model with the custom admin site +dovoodi_admin_site.register(ContentRelease, ContentReleaseAdmin) diff --git a/apps/hadis/apps.py b/apps/hadis/apps.py index 47fcf3d..4a21e14 100644 --- a/apps/hadis/apps.py +++ b/apps/hadis/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class HadisConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.hadis' + def ready(self): + # Import the signals module when the app starts + import apps.hadis.signals diff --git a/apps/hadis/docs.py b/apps/hadis/docs.py new file mode 100644 index 0000000..533f833 --- /dev/null +++ b/apps/hadis/docs.py @@ -0,0 +1,2466 @@ +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework import status + +# Swagger documentation for HadisSectListView +hadis_sect_list_swagger = swagger_auto_schema( + operation_description="Get list of all active Hadis sects grouped by sect type (Shia/Sunni)", + operation_summary="List Hadis Sects", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="List of hadis sects grouped by type with count", + examples={ + "application/json": { + "count": 2, + "next": 'null', + "previous": 'null', + "results": [ + { + "id": 20, + "sect_type": "shia", + "title": "Шииты-двунадесятники", + "order":1, + "description": [], + "source_types": [ + "hadith", + "quote", + "quran" + ] + }, + { + "id": 21, + "sect_type": "sunni", + "title": "Сунниты", + "order":1, + "description": [], + "source_types": [ + "fatwa", + "hadith", + "history", + "quran" + ] + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + + +# Swagger documentation for HadisCategoryTreeView +hadis_category_tree_swagger = swagger_auto_schema( + operation_description=( + "Get complete hierarchical tree of Hadis categories grouped by sect type (shia/sunni). " + "Categories are not grouped by source_type in the response (mobile filters source_type client-side). " + "This sync endpoint returns only category metadata (no hadis payload) for fast offline building of navigation trees. Includes sect information and full category tree with hadis counts and children counts." + ), + operation_summary="Get Complete Hadis Category Tree", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Complete hierarchical tree grouped by sect with category metadata including hadis counts and children counts", + examples={ + "application/json": { + "count": 6, + "results": { + "shia": { + "sects": { + "1": { + "id": 1, + "sect_type": "shia", + "title": "Shi'a Collections", + "description": "Shia roots", + "order": 1, + "source_types": [ + "hadith", + "quran", + "quote" + ] + + } + }, + "categories": [ + { + "id": 10, + "title": "Tafsir", + "description": "Quran commentary", + "slug": "-330", + "source_type": "quran", + "hadis_count": 2, + "has_hadis": False, + "order": 1, + "thumbnail": None, + "xmind_file": None, + "has_xmind_file": False, + "children_count": 3, + "children": [ + { + "id": 11, + "title": "Surah Al-Fatiha", + "description": "Opening chapter", + "slug": "-330", + "source_type": "quran", + "hadis_count": 2, + "has_hadis": True, + "order": 1, + "thumbnail": None, + "xmind_file": None, + "has_xmind_file": False, + "children_count": 3, + "children": [] + } + ] + } + ] + }, + "sunni": { + "sects": { + "2": { + "id": 2, + "sect_type": "sunni", + "title": "Sunni Collections", + "description": "Sunni roots", + "order": 2, + "source_types": [ + "hadith", + "quran", + "quote" + ] + } + }, + "categories": [ + { + "id": 20, + "title": "Book of Faith", + "description": "Iman topics", + "slug": "-330", + "source_type": "hadith", + "hadis_count": 1, + "has_hadis": True, + "order": 1, + "thumbnail": None, + "xmind_file": None, + "has_xmind_file": False, + "children_count": 3, + "children": [] + } + ] + } + } + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + + +# Swagger documentation for HadisSyncView + +hadis_sync_swagger = swagger_auto_schema( + operation_description="Get all Hadis data for offline sync. Returns a dictionary keyed by Hadis ID.", + operation_summary="Sync Hadis Data", + operation_id="syncHadisData", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'last_updated', + openapi.IN_QUERY, + description="Timestamp (ISO 8601) to fetch only modified records", + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successful Sync Response", + examples={ + "application/json": { + "count": 1, + "results":[ + { + "id": 1001, + "number": 45, + "slug": "достоинство-молитвы-и-ее-место-в-религии", + "category_id": 205, + "title": "The Reward of Intentions", + "title_narrator": "Imam Sadiq (as)", + "text": "إنما الأعمال بالنیات...", + "translation": "hadis translation", + "detail": + {"address": 'null', + "hadis_status": { + "id": 130, + "title": "Прерванный", + "color": "orange" + }, + "status_text": 'null', + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "tags": [ + { + "id": 520, + "title": "Постановления" + }, + { + "id": 514, + "title": "Терпение" + }, + ], + "references": [ + { + "id": 2193, + "title": 'null', + "authors": [], + "description": 'null' + } + ], + "reference_images": [ + { + "id": 1768, + "thumbnail": "http://127.0.0.1:8000/media/hadis/reference_images/ref_2193.png", + "priority": 0 + } + ] + }, + "narrators": { + "description": 'null', + "transmitters": [ + { + "id": 53, + "name": "Мухаммад ибн аль-Хасан ат-Туси", + "reliability": "unknown", + "layer_level": 'null', + "layer_name": 'null', + "is_gap": 'false', + "birth_year_hijri": 385, + "death_year_hijri": 460, + "order": 1 + }, + { + "id": 60, + "name": "Мухаммад ибн Муслим", + "reliability": "unknown", + "layer_level": 'null', + "layer_name": 'null', + "is_gap": 'false', + "birth_year_hijri": 70, + "death_year_hijri": 150, + "order": 2 + }, + ] + }, + "explanations": "Example explanation...", + "corrections": [ + { + "id":"id", + 'title':'title', + 'description':'description', + 'translation':'translation', + 'share_link':'share_link' + }, + ] + } + ] + } + } + ) + } +) + +# Swagger documentation for HadisListView +hadis_list_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a paginated list of Hadis (traditions) for a specific category. + + **Key Features:** + - Returns hadis entries filtered by category ID + - Supports pagination for large datasets + - Translations are automatically provided based on the Accept-Language header + - Each hadis includes its category information, title, narrator, Arabic text, and translation + + **Usage:** + - Use this endpoint to browse hadis within a specific category + - The response includes pagination links (next/previous) for navigation + - Set the Accept-Language header to get translations in your preferred language (en, fa, ar, ur) + - Only active (status=True) hadis are returned + + **Response Structure:** + - `count`: Total number of hadis in the category + - `next`: URL for the next page (null if on last page) + - `previous`: URL for the previous page (null if on first page) + - `results`: Array of hadis objects with full details + """, + operation_summary="List Hadis by Category", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'category_slug', + openapi.IN_PATH, + description="Unique identifier of the Hadis category. Must be a valid category ID that exists in the system.", + type=openapi.TYPE_STRING, + required=True, + example='-330' + ), + openapi.Parameter( + 'page', + openapi.IN_QUERY, + description="Page number for pagination. Starts from 1. If not provided, returns the first page.", + type=openapi.TYPE_INTEGER, + required=False, + example=1 + ), + openapi.Parameter( + 'Accept-Language', + openapi.IN_HEADER, + description="Language code for translations. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu). Defaults to 'en' if not specified.", + type=openapi.TYPE_STRING, + required=False, + default='en', + enum=['en', 'fa', 'ar', 'ur'] + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved paginated list of hadis for the specified category", + examples={ + "application/json": { + "count": 150, + "next": "http://example.com/api/hadis/category/1/?page=2", + "previous": None, + "results": [ + { + "id": 1, + "number": 1, + "title": "The Opening", + "title_narrator": "From Abu Hurairah", + "text": "إنما الأعمال بالنيات وإنما لكل امرئ ما نوى", + "translation": "Actions are but by intention, and every man shall have only what he intended", + "category": { + "id": 1, + "title": "Book of Faith", + "slug": "book-of-faith", + "source_type": "hadith", + "sect_type": "sunni" + }, + "status": { + "id": 130, + "title": "Прерванный", + "color": "orange" + }, + "share_link": "http://example.com/hadis/1" + }, + { + "id": 2, + "number": 2, + "title": "The Second Hadith", + "title_narrator": "From Umar ibn al-Khattab", + "text": "بينما نحن عند رسول الله صلى الله عليه وسلم ذات يوم", + "translation": "While we were sitting with the Messenger of Allah (peace be upon him) one day", + "category": { + "id": 1, + "title": "Book of Faith", + "slug": "book-of-faith", + "source_type": "hadith", + "sect_type": "sunni" + }, + "status": { + "id": 130, + "title": "Прерванный", + "color": "orange" + }, + "share_link": "http://example.com/hadis/2" + } + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="The specified category ID does not exist or the category has no active hadis", + examples={ + "application/json": { + "detail": "Not found." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +arguments_filters_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a paginated list of Hadis (traditions) for a specific category. + + **Key Features:** + - Returns hadis entries filtered by category ID + - Supports pagination for large datasets + - Translations are automatically provided based on the Accept-Language header + - Each hadis includes its category information, title, narrator, Arabic text, and translation + + **Usage:** + - Use this endpoint to browse hadis within a specific category + - The response includes pagination links (next/previous) for navigation + - Set the Accept-Language header to get translations in your preferred language (en, fa, ar, ur) + - Only active (status=True) hadis are returned + + **Response Structure:** + - `count`: Total number of hadis in the category + - `next`: URL for the next page (null if on last page) + - `previous`: URL for the previous page (null if on first page) + - `results`: Array of hadis objects with full details + """, + operation_summary="List Hadis by Category", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'Accept-Language', + openapi.IN_HEADER, + description="Language code for translations. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu). Defaults to 'en' if not specified.", + type=openapi.TYPE_STRING, + required=False, + default='en', + enum=['en', 'fa', 'ar', 'ur'] + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved paginated list of hadis for the specified category", + examples={ + "application/json": { + "statuses": [ + { + "text": "Достоверный", + "slug": "-0" + }, + { + "text": "Authentic / Accepted", + "slug": "authentic-accepted-1" + }, + { + "text": "Weak / Needs Review", + "slug": "weak-needs-review-2" + }, + { + "text": "Хороший", + "slug": "-3" + }, + ], + "categories": [ + { + "text": "Толкование суры Аль-Фатиха", + "slug": "-330" + }, + { + "text": "Аяты о молитве", + "slug": "-15" + }, + { + "text": "Истории пророков", + "slug": "-14" + }, + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="The specified category ID does not exist or the category has no active hadis", + examples={ + "application/json": { + "detail": "Not found." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +hadis_basic_swagger = swagger_auto_schema( + operation_description="Get basic information about a specific hadis including core text and translation", + operation_summary="Get Hadis Basic Info", + operation_id="getHadisBasic", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'hadis_slug', + openapi.IN_PATH, + description="Slug of the hadis", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Basic hadis information", + examples={ + "application/json": { + "id": 1, + "slug": "достоинство-молитвы-и-ее-место-в-религии", + "title": "The Opening", + "title_narrator": "From Abu Hurairah", + "text": "Actions are but by intention...", + "translation": "Actions are but by intention...", + "share_link": "http://example.com/hadis/1", + 'explanation': "This hadith emphasizes the importance of intention in all actions...", + "category": { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "slug": "cat-slug", + "source_type": "quran", + "sect_type": "shia" + } + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Hadis not found" + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +hadis_detail_swagger = swagger_auto_schema( + operation_summary="Get Hadis Detail Metadata", + operation_description="Retrieve metadata for a specific hadis including Status, Tags, External Links, and Book References. (Note: Text and Narrators are in separate endpoints).", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'hadis_slug', + openapi.IN_PATH, + description="Slug of the hadis", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Hadis metadata details", + examples={ + "application/json": { + "id": 1800, + "slug": "достоинство-молитвы-и-ее-место-в-религии", + "number": 1, + "hadis_status_text": "Agreed upon by all scholars", + "hadis_status": { + "id": 130, + "title": "Weak / Discontinued", + "color": "orange" + }, + "links": [ + { + "link": "https://example.com/source1", + "title": "Online Reference 1" + }, + { + "link": "https://example.com/source2", + "title": "PDF Source" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/1800", + "tags": [ + { + "id": 510, + "title": "Hajj (Pilgrimage)" + }, + { + "id": 520, + "title": "Legal Rulings" + } + ], + "references": [ + { + "id": 2193, + "book_title": "Sahih al-Bukhari", + "book_authors": "Imam Bukhari", + "book_description": "Chapter on Patience, Vol 2" + } + ], + "reference_images": [ + { + "id": 1768, + "thumbnail": "http://api.site.com/media/hadis/reference_images/scan_01.png", + "priority": 0 + } + ], + "address": "Page 54, Volume 2, Line 10" + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Hadis not found" + ) + } +) + + +hadis_transmitters_swagger = swagger_auto_schema( + operation_description="Get the chain of transmitters for a specific Hadis. Returns the Hadis ID, the count of unique narrator generations (layers), and the ordered list of transmitters.", + operation_summary="Get Hadis Chain (Isnad)", + operation_id="getHadisChain", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + # Path Parameter: ID + openapi.Parameter( + 'hadis_slug', + openapi.IN_PATH, + description="The Slug of the Hadis to fetch", + type=openapi.TYPE_STRING, + required=True + ), + # Query Parameter: Layer Filter (Optional) + openapi.Parameter( + 'layer', + openapi.IN_QUERY, + description="Filter transmitters by narrator layer slug (e.g., ?layer=sahaba)", + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successful retrieval of the transmission chain.", + examples={ + "application/json": { + "id": 1830, + "layer_count": 2, + "layer_names": [ + "Students of the Imams", + "Minor Narrators" + ], + "results": [ + { + "id": 6609, + "order": 0, + "is_gap": False, + "narrator_layer_description": "Lesser known narrators", + "layer": "minor-narrators", + "transmitter": { + "id": 90, + "full_name": "Sahl bin Ziyad", + "birth_year_hijri": 'null', + "death_year_hijri": 255, + "known_as": 'null', + "nickname": 'null', + "reliability": 10 + }, + "status": { + "id": 10, + "title": "Weak", + "slug": "weak", + "color": "orange" + } + }, + { + "id": 6610, + "order": 1, + "is_gap": False, + "narrator_layer_description": "Lesser known narrators", + "layer": "minor-narrators", + "transmitter": { + "id": 87, + "full_name": "Yunus bin Abd al-Rahman", + "birth_year_hijri": 'null', + "death_year_hijri": 208, + "known_as": 'null', + "nickname": 'null', + "reliability": 7 + }, + "status": { + "id": 7, + "title": "Very Reliable", + "slug": "very-reliable", + "color": "green" + } + }, + { + "id": 6611, + "order": 2, + "is_gap": False, + "narrator_layer_description": "Direct students of the Imams", + "layer": "students-of-the-imams", + "transmitter": { + "id": 84, + "full_name": "Zurarah bin Ayan", + "birth_year_hijri": 'null', + "death_year_hijri": 150, + "known_as": 'null', + "nickname": 'null', + "reliability": 7 + }, + "status": { + "id": 7, + "title": "Very Reliable", + "slug": "very-reliable", + "color": "green" + } + } + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Hadis not found" + ) + } +) + +hadis_corrections_swagger = swagger_auto_schema( + operation_description=""" + Retrieve all corrections, improvements, and scholarly notes for a specific hadis. + + **Key Features:** + - Returns all corrections associated with a hadis + - Includes translation corrections in multiple languages + - Provides scholarly improvements and clarifications + - Useful for understanding updates and refinements to hadis content + + **Usage:** + - Use this endpoint to get corrections and improvements for a hadis + - Corrections may include updated translations, authenticity clarifications, or scholarly notes + - The `translation` field contains multilingual corrections (typically JSON format) + - Each correction has a title and description explaining the nature of the correction + + **Response Structure:** + - `hadis_id`: The ID of the hadis these corrections belong to + - `corrections_count`: Total number of corrections available + - `corrections`: Array of correction objects with details + + **Note:** + - If no corrections exist, the corrections array will be empty + - Corrections are ordered by creation date (newest first) + - Translation corrections may be in JSON format with language codes as keys + """, + operation_summary="Get Hadis Corrections", + operation_id="getHadisCorrections", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'hadis_slug', + openapi.IN_PATH, + description="Slug of the hadis. Must be a valid hadis slug that exists in the system. Only active hadis (status=True) are accessible.", + type=openapi.TYPE_STRING, + required=True, + example=1 + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved corrections for the specified hadis", + examples={ + "application/json": { + "hadis_id": 1800, + "corrections_count": 2, + "corrections": [ + { + "id": 1, + "title": "Translation Correction", + "slug":"достоинство-молитвы-и-ее-место-в-религии", + "description": "Updated translation for better accuracy and clarity. The previous translation was slightly ambiguous in the context of intention and action.", + "translation":"Actions are judged by intentions, and every person will have what they intended", + "share_link": 'null' + }, + { + "id": 2, + "title": "Authenticity Clarification", + "slug":"достоинство-молитвы-и-ее-место-в-религии", + "description": "Additional scholarly notes on the authenticity and grading of this hadith. This hadith is considered authentic (sahih) by consensus of scholars.", + "translation":"Actions are judged by intentions, and every person will have what they intended", + "share_link": 'null' + } + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="The specified hadis ID does not exist or the hadis is not active", + examples={ + "application/json": { + "detail": "Not found." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +# Swagger documentation for HadisCollectionListView +hadis_collections_swagger = swagger_auto_schema( + operation_description="Get list of all active hadis collections for browsing and categorization", + operation_summary="List Hadis Collections", + operation_id="getHadisCollections", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="List of hadis collections", + examples={ + "application/json": [ + { + "count": 2, + "next": 'null', + "previous": 'null', + "results": [ + { + "id": 9, + "title": "The Book of Intellect and Ignorance", + "summary": "A collection of narrations regarding the importance of reason.", + "slug": "the-book-of-intellect-and-ignorance", + "thumbnail": 'null' + }, + { + "id": 10, + "title": "The Book of Monotheism", + "summary": "Hadiths explaining the oneness of God.", + "slug": "the-book-of-monotheism", + "thumbnail": 'null' + } + ] + } + ] + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + + +# Swagger documentation for HadisInfoView +hadis_info_swagger = swagger_auto_schema( + operation_description="Get statistical information about hadis database including counts of categories, references, bookmarks, and narrators", + operation_summary="Get Hadis Statistics", + operation_id="getHadisInfo", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Hadis database statistics", + examples={ + "application/json":{ + "hadis_count": 65, + "category_count": 67, + "reference_count": 1, + "bookmark_count": 0, + "narrator_count": 8 + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# Swagger documentation for TransmitterView +transmitter_list_swagger = swagger_auto_schema( + operation_description="Get a paginated list of transmitters (narrators) with optional filtering by reliability, madhhab, and generation.", + operation_summary="List Transmitters", + operation_id="listTransmitters", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'status', + openapi.IN_QUERY, + description='Filter by reliability status (e.g. very_reliable, weak)', + type=openapi.TYPE_STRING, + required=False + ), + openapi.Parameter( + 'madhhab', + openapi.IN_QUERY, + description='Filter by madhhab (e.g. shia, sunni)', + type=openapi.TYPE_STRING, + required=False + ), + openapi.Parameter( + 'generation', + openapi.IN_QUERY, + description='Filter by generation integer', + type=openapi.TYPE_INTEGER, + required=False + ), + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Paginated list of transmitters", + examples={ + "application/json": { + "count": 10, + "next": "http://api.example.com/transmitters/?page=2", + "previous": None, + "results": [ + { + "id": 56, + "full_name": "Абу Дауд ас-Сиджистани", + "slug": "abdullah-ibn-abbas", + "birth_year_hijri": 202, + "death_year_hijri": 275, + "known_as": "Imam Abu Daud", + "nickname": "Al-Sijistani", + "reliability": { + "id": 7, + "title": "Very Reliable", + "color": "green" + }, + "madhhab": "sunni", + "generation": 3 + }, + { + "id": 51, + "full_name": "Мухаммад ибн Якуб Кулейни", + "slug": "abdullah-ibn-abbas", + "birth_year_hijri": 250, + "death_year_hijri": 329, + "known_as": "Thiqat al-Islam", + "nickname": None, + "reliability": { + "id": 7, + "title": "Very Reliable", + "color": "green" + }, + "madhhab": "shia", + "generation": 4 + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +transmitter_filters_swagger = swagger_auto_schema( + operation_description="Get the needed data for filter lists such as madhabs,generations,reliabilities.", + operation_summary="Transmitter's List's filters", + operation_id="transmiterfilter", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'Accept-Language', + openapi.IN_HEADER, + description="Language code for localized content. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu), 'ru' (Russian). Defaults to 'en' if not specified.", + type=openapi.TYPE_STRING, + required=False, + default='en', + enum=['en', 'fa', 'ar', 'ur', 'ru'] + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="list of transmitters filter data", + examples={ + "application/json": { + "generations": [1,2,3,4,5], + "madhabs": [ + "shia", + "sunni" + ], + "reliabilities": [ + { + "text": "Very Reliable", + "slug": "very-reliable" + }, + { + "text": "Reliable", + "slug": "reliable" + }, + { + "text": "Acceptable", + "slug": "acceptable" + }, + { + "text": "Weak", + "slug": "weak" + }, + { + "text": "Very Weak", + "slug": "very-weak" + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# Swagger documentation for TransmitterDetailView +transmitter_detail_swagger = swagger_auto_schema( + operation_description="Get detailed information about a specific transmitter including their scholarly opinions and hadith transmissions", + operation_summary="Get Transmitter Details", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'narrator_slug', + openapi.IN_PATH, + description="Slug of narrator", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Detailed transmitter information with nested opinions and transmissions", + examples={ + "application/json": { + "id": 1, + "full_name": "Abu Daud Sulaiman ibn al-Ash'ath al-Azdi al-Sijistani", + "kunya": "Abu Daud", + "known_as": "Imam Abu Daud", + "nickname": "Al-Sijistani", + "origin": "Sijistan (modern Sistan)", + "lived_in": "Basra, Baghdad", + "died_in": "Basra", + "birth_year_hijri": 202, + "death_year_hijri": 275, + "age_at_death": 73, + "reliability": { + "id": 7, + "title": "Very Reliable", + "color": "green" + }, + "madhhab": "sunni", + "in_sahih_bukhari": False, + "in_sahih_muslim": True, + "description": "Imam Abu Daud, compiler of Sunan Abu Daud", + "generation": 3 + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Transmitter not found" + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework import status + +transmitter_sync_swagger = swagger_auto_schema( + operation_description="Get complete transmitter (narrator) data for offline synchronization. Returns a flat list of narrators with biographical info.", + operation_summary="Sync Transmitter Data", + operation_id="syncTransmitterData", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Complete transmitter data list", + examples={ + "application/json": { + "count": 10, + "results": [ + { + "id": 51, + "full_name": "Мухаммад ибн Якуб Кулейни", + "slug": "zurarah-bin-ayan", + "biographical": { + "full_name": "Мухаммад ибн Якуб Кулейни", + "kunya": None, + "known_as": None, + "nickname": None, + "origin": None, + "lived_in": None, + "died_in": None, + "birth_year_hijri": 250, + "death_year_hijri": 329, + "age_at_death": None, + "generation": None, + "reliability": { + "id": 7, + "title": "Very Reliable", + "color": "green" + }, + "madhhab": "unknown", + "in_sahih_muslim": False, + "in_sahih_bukhari": False, + "description": "Шейх Кулейни, автор книги Аль-Кафи и один из великих мухаддисов шиитов", + "thumbnail": None + }, + "scholars_opinions": [ + { + "id": 2, + "scholar_name": "test sc2h", + "opinion_text": "some2 opinions", + "status": { + "id": 1, + "title": "accepted", + "slug": "accepted", + "color": "green" + }, + "created_at": "2025-12-16T11:38:19.646613", + "updated_at": "2025-12-16T11:38:19.646613" + }, + { + "id": 1, + "scholar_name": "test sch", + "opinion_text": "some opinions", + "status": { + "id": 1, + "title": "accepted", + "slug": "accepted", + "color": "green" + }, + "created_at": "2025-12-16T11:38:02.679871", + "updated_at": "2025-12-16T11:38:02.679871" + } + ], + "original_texts": [ + { + "id": 1, + "title": "text title", + "text": "", + "translation":"text translation", + "share_link": "http/exmaokns.com" + }, + { + "id": 2, + "title": "text2 title", + "text": "", + "translation":"text translation", + "share_link": "http/exmaokns.com" + } + ] + }, + { + "id": 56, + "full_name": "Абу Дауд ас-Сиджистани", + "slug": "zurarah-bin-ayan", + "biographical": { + "full_name": "Абу Дауд ас-Сиджистани", + "kunya": "Abu Daud", + "known_as": "Imam Abu Daud", + "nickname": "Al-Sijistani", + "origin": "Sijistan", + "lived_in": "Basra", + "died_in": "Basra", + "birth_year_hijri": 202, + "death_year_hijri": 275, + "age_at_death": 73, + "generation": 3, + "reliability": { + "id": 7, + "title": "Very Reliable", + "color": "green" + }, + "madhhab": "shafii", + "in_sahih_muslim": False, + "in_sahih_bukhari": False, + "description": "Имам Абу Дауд, автор Сунан Абу Дауд", + "thumbnail": "http://example.com/media/abu_daud.jpg" + }, + "scholars_opinions": [], + "original_texts": [] + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# Swagger documentation for TransmitterOpinionView +transmitter_opinion_swagger = swagger_auto_schema( + operation_description=""" + Retrieve all scholarly opinions about a specific transmitter (narrator). + + **Key Features:** + - Returns all opinions from various scholars about the transmitter's reliability and character + - Includes opinion status (confirmed, mixed, rejected) + - Provides detailed opinion text from each scholar + - Useful for understanding the scholarly consensus on a narrator's reliability + + **Usage:** + - Use this endpoint to get scholarly assessments of a transmitter + - Opinions help determine the reliability and authenticity of narrations + - The `status` field indicates whether the opinion is confirmed, mixed, or rejected + - Each opinion includes the scholar's name and their detailed assessment + + **Response Structure:** + - Returns an array of opinion objects + - Each opinion includes scholar name, opinion text, status, and timestamps + - Opinions are ordered by creation date (newest first) + + **Opinion Status Values:** + - `confirmed`: The opinion is confirmed and widely accepted + - `mixed`: There are mixed views about this opinion + - `rejected`: The opinion has been rejected or is not widely accepted + """, + operation_summary="Get Transmitter Opinions", + operation_id="getTransmitterOpinions", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'narrator_slug', + openapi.IN_PATH, + description="Narrator's slug", + type=openapi.TYPE_STRING, + required=True, + example="abdullah-ibn-abbas" + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved all scholarly opinions for the specified transmitter", + examples={ + "application/json": [ + { + "id": 1, + "transmitter": 56, + "scholar_name": "Ibn Hajar al-Asqalani", + "opinion_text": "He was a reliable and trustworthy narrator. His narrations are accepted and he is considered among the reliable transmitters of hadith. He had good memory and was known for his accuracy in transmission.", + "status": { + "id": 1, + "title": "accepted", + "slug": "accepted", + "color": "green" + } + }, + { + "id": 2, + "transmitter": 56, + "scholar_name": "Imam al-Dhahabi", + "opinion_text": "A reliable narrator with good character. His narrations are generally accepted, though some scholars have noted minor issues in certain chains of transmission.", + "status": { + "id": 1, + "title": "accepted", + "slug": "accepted", + "color": "green" + } + }, + { + "id": 3, + "transmitter": 56, + "scholar_name": "Ibn Ma'in", + "opinion_text": "Trustworthy and reliable. His narrations are sound and he is considered among the reliable transmitters.", + "status": { + "id": 1, + "title": "accepted", + "slug": "accepted", + "color": "green" + } + } + ] + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="The specified transmitter ID does not exist", + examples={ + "application/json": { + "detail": "Not found." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + +# Swagger documentation for TransmitterOriginalTextView +transmitter_original_text_swagger = swagger_auto_schema( + operation_description=""" + Retrieve all original texts associated with a specific transmitter (narrator). + + **Key Features:** + - Returns all original texts (writings, sayings, or works) attributed to the transmitter + - Includes original Arabic text and translations in multiple languages + - Provides shareable links for each text + - Useful for accessing the original works and writings of narrators + + **Usage:** + - Use this endpoint to get original texts written or attributed to a transmitter + - Original texts may include their writings, sayings, or scholarly works + - The `translation` field contains multilingual translations (typically JSON format) + - Each text has a shareable link for easy sharing + + **Response Structure:** + - Returns an array of original text objects + - Each text includes title, original text, translations, and share link + - Texts are ordered by creation date (newest first) + + **Translation Format:** + - The translation field is typically a JSON array or object + - May contain translations in multiple languages + - Format may vary based on how translations are stored + """, + operation_summary="Get Transmitter Original Texts", + operation_id="getTransmitterOriginalTexts", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'narrator_slug', + openapi.IN_PATH, + description="Narrator's slug", + type=openapi.TYPE_STRING, + required=True, + example="abdullah-ibn-abbas" + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved all original texts for the specified transmitter", + examples={ + "application/json": [ + { + "id": 1, + "title": "On the Importance of Intention", + "text": "إنما الأعمال بالنيات وإنما لكل امرئ ما نوى. فمن كانت هجرته إلى الله ورسوله فهجرته إلى الله ورسوله، ومن كانت هجرته لدنيا يصيبها أو امرأة ينكحها فهجرته إلى ما هاجر إليه", + "translation":"Actions are but by intention, and every person will have what they intended. So whoever emigrated for Allah and His Messenger, then his emigration is for Allah and His Messenger. And whoever emigrated for worldly gain or to marry a woman, then his emigration is for that which he emigrated.", + "share_link": "http://example.com/narrators/56/texts/1" + }, + { + "id": 2, + "title": "On Knowledge and Learning", + "text": "طلب العلم فريضة على كل مسلم ومسلمة", + "translation":"Actions are but by intention, and every person will have what they intended. So whoever emigrated for Allah and His Messenger, then his emigration is for Allah and His Messenger. And whoever emigrated for worldly gain or to marry a woman, then his emigration is for that which he emigrated.", + "share_link": "http://example.com/narrators/56/texts/2" + } + ] + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="The specified transmitter ID does not exist", + examples={ + "application/json": { + "detail": "Not found." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +# Swagger documentation for BookReferencesView +book_references_list_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a paginated list of all book references used in hadith studies. + + **Key Features:** + - Returns a paginated list of book references + - Supports search functionality for book titles + - Includes metadata such as title, description, rating, and volume info + + **Search Functionality:** + - Search parameter performs case-insensitive search across book titles + - Supports partial matching for easy discovery + + **Response Structure:** + - `count`: Total number of books + - `results`: Array of book objects + - `rate`: The book's rating (returned as a string, e.g., "1.20") + - `author`: List of authors associated with the book + - `volume`: Volume information (string) + """, + operation_summary="List Book References", + operation_id="listBookReferences", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'search', + openapi.IN_QUERY, + description="Search term to filter book references by title. Case-insensitive partial matching.", + type=openapi.TYPE_STRING, + required=False, + example="sahih" + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Paginated list of book references", + examples={ + "application/json": { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": 4, + "title": "book2", + "slug": "al-mizan", + "rate": "1.20", + "author": [ + { + 'id': 5, + 'name': 'author.name' + }, + { + 'id': 6, + 'name': 'author.name' + } + ], + "description": "book desc", + "volume": "9" + }, + { + "id": 2, + "title": "book", + "slug": "al-mizan", + "rate": "1.20", + "author": [ + { + "id": 1, + "name": "Author Name" + } + ], + "description": "book desc", + "volume": "9" + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +reference_sync_swagger = swagger_auto_schema( + operation_description="Get complete book reference data for offline synchronization. Returns a paginated list of books with detailed metadata.", + operation_summary="Sync Book References Data", + operation_id="syncReferenceData", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Complete book references data list", + examples={ + "application/json": { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": 2, + "title": "Sahih Muslim", + "rate": "4.80", + "author": [ + { + "id": 55, + "name": "Muslim ibn al-Hajjaj" + } + ], + "detail": { + "description": "One of the six major collections of Sunni hadith", + "volume": "7", + "language": "arabic", + "isbn": "978-1234567890", + "year_of_publication": "261 AH", + "number_of_pages": 1200, + "volume_info": "7 Volumes", + "rating": 4.8 + }, + "image": [ + { + "id": 101, + "image": "http://api.site.com/media/books/muslim_cover.jpg", + "description": "Front Cover", + "order": 1 + } + ], + "attribute": [ + { + "id": 10, + "title": "Publisher", + "value": "Dar-us-Salam", + "book_reference": 2 + }, + { + "id": 11, + "title": "Edition", + "value": "2nd Edition", + "book_reference": 2 + } + ], + "hadises": [ + { + "id": 1001, + "title": "The Book of Faith", + "title_narrator": "Abu Huraira", + "text": "Faith has seventy-odd branches...", + "translation": "Faith has seventy-odd branches...", + "share_link": "http://site.com/hadis/1001", + } + ] + }, + { + "id": 4, + "title": "Al-Kafi", + "rate": "4.90", + "author": [ + { + "id": 51, + "name": "Sheikh Kulayni" + } + ], + "detail": { + "description": "The most important Shia hadith collection", + "volume": "8", + "language": "arabic", + "isbn": "978-9876543210", + "year_of_publication": "329 AH", + "number_of_pages": 3500, + "volume_info": "8 Volumes", + "rating": 4.9 + }, + "image": [ + { + "id": 102, + "image": "http://api.site.com/media/books/kafi_cover.jpg", + "description": "Volume 1 Cover", + "order": 1 + } + ], + "attribute": [ + { + "id": 12, + "title": "Publisher", + "value": "Dar al-Kutub al-Islamiyya", + "book_reference": 4 + } + ], + "hadises": [] + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# Swagger documentation for BookAuthorView +book_authors_list_swagger = swagger_auto_schema( + operation_description="Get list of all book authors who have contributed to hadith literature", + operation_summary="List Book Authors", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="List of all book authors", + examples={ + "application/json": [ + { + "id": 1, + "name": "Muhammad ibn Isma'il al-Bukhari", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + }, + { + "id": 2, + "name": "Muslim ibn al-Hajjaj al-Qushayri", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ] + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# ============================================================================ +# GET - List all book attributes +# ============================================================================ +book_attributes_list_swagger = swagger_auto_schema( + operation_description="Retrieve all custom attributes for books. Optionally filter by book reference ID or attribute title.", + operation_summary="List Book Attributes", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'book_reference', + openapi.IN_QUERY, + description='Filter attributes by book reference ID (optional)', + type=openapi.TYPE_INTEGER, + required=False, + ), + openapi.Parameter( + 'title', + openapi.IN_QUERY, + description='Filter attributes by title - partial match (optional)', + type=openapi.TYPE_STRING, + required=False, + ), + openapi.Parameter( + 'limit', + openapi.IN_QUERY, + description='Number of results per page', + type=openapi.TYPE_INTEGER, + required=False, + ), + openapi.Parameter( + 'offset', + openapi.IN_QUERY, + description='Starting index for pagination', + type=openapi.TYPE_INTEGER, + required=False, + ), + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="List of book attributes retrieved successfully", + examples={ + "application/json": { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": 1, + "title": "Number of Hadith", + "value": "7,563", + "book_reference": 1 + }, + { + "id": 2, + "title": "Authenticity Grade", + "value": "Sahih (Authentic)", + "book_reference": 1 + } + ] + } + } + ), + status.HTTP_400_BAD_REQUEST: openapi.Response( + description="Invalid query parameters", + examples={ + "application/json": { + "error": "Invalid book_reference ID" + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# ============================================================================ +# POST - Create a new book attribute +# ============================================================================ +book_attributes_create_swagger = swagger_auto_schema( + operation_description="Create a new custom attribute for a book. Attributes can store additional metadata about hadith books such as number of hadith, authenticity grade, or other relevant information.", + operation_summary="Create Book Attribute", + tags=['Dobodbi - Hadis'], + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['title', 'value', 'book_reference'], + properties={ + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description='Attribute title/name (e.g., "Number of Hadith")', + example='Collection Type' + ), + 'value': openapi.Schema( + type=openapi.TYPE_STRING, + description='Attribute value (e.g., "7,563")', + example='Hadith Compilation' + ), + 'book_reference': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='ID of the book this attribute belongs to', + example=2 + ), + } + ), + responses={ + status.HTTP_201_CREATED: openapi.Response( + description="Book attribute created successfully", + examples={ + "application/json": { + "id": 3, + "title": "Collection Type", + "value": "Hadith Compilation", + "book_reference": 2 + } + } + ), + status.HTTP_400_BAD_REQUEST: openapi.Response( + description="Invalid input data", + examples={ + "application/json": { + "title": ["This field is required."], + "value": ["This field is required."], + "book_reference": ["Invalid book reference ID."] + } + } + ), + status.HTTP_401_UNAUTHORIZED: openapi.Response( + description="Authentication required - provide a valid token" + ), + status.HTTP_403_FORBIDDEN: openapi.Response( + description="Permission denied - you do not have permission to create attributes" + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error" + ) + } +) + +# Swagger documentation for BookDetailView +book_detail_swagger = swagger_auto_schema( + operation_description=""" + Retrieve detailed information about a specific book reference. + + **Key Features:** + - Returns comprehensive details including attributes, authors, images, and referenced hadis. + - Matches the `BookDetailSerializer` structure. + + **Response Structure:** + - `attribute`: List of extra book details (e.g., ISBN, Year, Pages). + - `author`: List of authors. + - `image`: List of book covers or reference images. + - `hadis`: List of Hadis that reference this book. + """, + operation_summary="Get Book Reference Details", + operation_id="getBookReferenceDetails", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'reference_slug', + openapi.IN_PATH, + description="Unique slug of the book reference", + type=openapi.TYPE_STRING, + required=True, + example='sunan-ibn-majah' + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Detailed book information", + examples={ + "application/json": { + "id": 2, + "title": "Sahih Muslim", + "rate": "4.80", + 'isbn':"13135487d", + 'language':'arabic', + 'number_page':1000, + "publisher": "Dar-us-Salam Publications", + "description": "One of the six major collections of Sunni hadith, recognized as authentic.", + "volume": "7 Volumes", + "slug": "sahih-muslim", + "attribute": [ + { + "id": 10, + "title": "ISBN", + "value": "978-1234567890" + }, + { + "id": 11, + "title": "Pages", + "value": "3000" + }, + { + "id": 12, + "title": "Language", + "value": "Arabic/English" + } + ], + "author": [ + { + "id": 55, + "name": "Muslim ibn al-Hajjaj" + } + ], + "image": [ + { + "id": 5, + "image": "http://api.site.com/media/books/muslim_cover.jpg", + "description": "Front Cover", + "order": 1 + } + ], + "hadis": [ + { + "id": 105, + "title": "Hadith on Intention", + "title_narrator": "Umar ibn Khattab", + "text": "Actions are by intentions...", + "translation": "Actions are but by intentions...", + "share_link": "http://site.com/hadis/105", + "explanation": "This hadith signifies the importance of niyyah.", + "category": { + "id": 50, + "title": "Book of Faith" + } + } + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Book reference not found" + ) + } +) + + +# Swagger documentation for CategoriesView +categories_list_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a comprehensive list of all Hadis categories in the system. + + **Key Features:** + - Returns all categories regardless of sect type or source type + - Includes hierarchical information (children count) + - Provides category metadata including sect association, source type, and description + - Shows whether categories have direct hadis entries or child categories + + **Usage:** + - Use this endpoint to get an overview of all available categories + - The `children_count` field indicates how many subcategories exist + - The `has_hadis` field shows if the category directly contains hadis (no children) + - The `hadis_count` field shows the total number of hadis in this category + + **Response Fields:** + - `id`: Unique category identifier + - `title`: Category name + - `sect_id`: Associated sect ID + - `sect_type`: Type of Islamic sect (shia/sunni) + - `source_type`: Type of source material (quran, hadith, history, fatwa, quote) + - `description`: Detailed description of the category + - `slug`: URL-friendly identifier + - `children_count`: Number of direct child categories + - `has_hadis`: Boolean indicating if category has direct hadis entries + - `hadis_count`: Total number of hadis in this category + """, + operation_summary="List All Categories", + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved list of all hadith categories", + examples={ + "application/json": [ + { + "id": 1, + "title": "Book of Faith", + "sect_id": 1, + "sect_type": "sunni", + "source_type": "hadith", + "description": "Hadiths related to Islamic faith and beliefs, including articles of faith, belief in Allah, angels, books, messengers, and the Last Day", + "slug": "book-of-faith", + "children_count": 3, + "has_hadis": False, + "hadis_count": 0 + }, + { + "id": 2, + "title": "Book of Prayer", + "sect_id": 1, + "sect_type": "sunni", + "source_type": "hadith", + "description": "Hadiths about salah (prayer) and related rulings, including prayer times, conditions, and etiquettes", + "slug": "book-of-prayer", + "children_count": 0, + "has_hadis": True, + "hadis_count": 45 + }, + { + "id": 10, + "title": "Tafsir of Surah Al-Fatiha", + "sect_id": 2, + "sect_type": "shia", + "source_type": "quran", + "description": "Commentary and interpretation of the opening chapter of the Quran", + "slug": "tafsir-al-fatiha", + "children_count": 0, + "has_hadis": True, + "hadis_count": 12 + } + ] + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +# Swagger documentation for CategoriesBySectView +categories_by_sect_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a paginated list of Hadis categories filtered by Islamic sect type. + + **Key Features:** + - Filters categories by sect type (shia or sunni) + - Returns all categories belonging to the specified sect + - Includes hierarchical information (`children_count`) and hadis counts + - Useful for building sect-specific category navigation + + **Usage:** + - Use this endpoint to get root categories for a specific Islamic sect + - The `sect_type` parameter must be either 'shia' or 'sunni' + - Use `next` and `previous` fields for traversing pages + + **Response Structure:** + - Returns a paginated object containing `count` and a `results` array + - The `has_hadis` field indicates if the category can be used to fetch hadis directly + """, + operation_summary="List Categories by Sect", + operation_id="listCategoriesBySect", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'sect_type', + openapi.IN_PATH, + description="Type of Islamic sect. Must be either 'shia' or 'sunni'.", + type=openapi.TYPE_STRING, + enum=['shia', 'sunni'], + required=True, + example='sunni' + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Paginated list of categories for the specified sect", + examples={ + "application/json": { + "count": 28, + "next": "http://api.site.com/api/hadis/categories/sunni/?limit=16&offset=16", + "previous": None, + "results": [ + { + "id": 352, + "title": "Толкование Корана", + "sect_id": 21, + "sect_type": "sunni", + "source_type": "quran", + "description": None, + "slug": "-13", + "children_count": 3, + "has_hadis": False, + "hadis_count": 0 + }, + { + "id": 358, + "title": "Толкование суры Аль-Фатиха", + "sect_id": 21, + "sect_type": "sunni", + "source_type": "quran", + "description": None, + "slug": "-12", + "children_count": 0, + "has_hadis": False, + "hadis_count": 0 + }, + { + "id": 366, + "title": "Книга очищения", + "sect_id": 21, + "sect_type": "sunni", + "source_type": "hadith", + "description": None, + "slug": "-10", + "children_count": 3, + "has_hadis": False, + "hadis_count": 0 + } + ] + } + } + ), + status.HTTP_400_BAD_REQUEST: openapi.Response( + description="Invalid parameters", + examples={ + "application/json": { + "detail": "Invalid sect type. Must be 'shia' or 'sunni'." + } + } + ) + } +) + +# Swagger documentation for HadisCategoryTreeBySectView +categories_tree_by_sect_swagger = swagger_auto_schema( + operation_description=""" + Retrieve child categories of a specific parent category within a given Islamic sect type. + + **Key Features:** + - Returns direct children of a parent category identified by slug + - Filters by sect type to ensure correct sect association + - Provides hierarchical navigation through category tree + - Includes information about whether categories have hadis or further children + + **Usage:** + - Use this endpoint to navigate through the category hierarchy + - The `slug` parameter identifies the parent category + - The `sect_type` ensures categories belong to the correct sect + - Use `children_count` to determine if a category has subcategories + - Use `has_hadis` to determine if you can fetch hadis directly from this category + + **Navigation Flow:** + 1. Start with root categories (use categories// endpoint) + 2. Use the slug from a category to get its children + 3. Continue navigating down the tree until you reach a category with `has_hadis: true` + 4. Use that category's ID to fetch hadis using the category// endpoint + """, + operation_summary="Get Category Children by Sect and Slug", + operation_id="getCategoryChildrenBySectAndSlug", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'sect_type', + openapi.IN_PATH, + description="Type of Islamic sect. Must be 'shia' or 'sunni'. This ensures categories belong to the correct sect.", + type=openapi.TYPE_STRING, + enum=['shia', 'sunni'], + required=True, + example='shia' + ), + openapi.Parameter( + 'slug', + openapi.IN_PATH, + description="URL-friendly slug identifier of the parent category. This is used to locate the parent category whose children you want to retrieve. The slug is typically derived from the category title.", + type=openapi.TYPE_STRING, + required=True, + example='book-of-faith' + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved list of child categories for the specified parent category", + examples={ + "application/json": { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "id": 330, + "title": "Tafsir of Surah Al-Fatiha", + "source_type": "quran", + "slug": "tafsir-al-fatiha", + "sect_id": 20, + "sect_type": "shia", + "description": "Commentary and interpretation of the opening chapter of the Quran", + "children_count": 0, + "has_hadis": True, + "hadis_count": 15 + }, + { + "id": 331, + "title": "Tafsir of Surah Al-Baqarah", + "source_type": "quran", + "slug": "tafsir-al-baqarah", + "sect_id": 20, + "sect_type": "shia", + "description": "Detailed explanation of Surah Al-Baqarah verses and their meanings", + "children_count": 0, + "has_hadis": True, + "hadis_count": 28 + }, + { + "id": 332, + "title": "Tafsir of Surah Al-Imran", + "source_type": "quran", + "slug": "tafsir-al-imran", + "sect_id": 20, + "sect_type": "shia", + "description": "Interpretation of Surah Al-Imran with scholarly insights", + "children_count": 0, + "has_hadis": True, + "hadis_count": 22 + } + + ] + } + } + + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Parent category with the specified slug not found in the given sect type", + examples={ + "application/json": { + "detail": "No categories found for the specified slug and sect type." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +# Swagger documentation for HadisCategoryTreeBySectSourceView +categories_tree_by_sect_source_swagger = swagger_auto_schema( + operation_description=""" + Retrieve child categories of a specific parent category, filtered by sect type and source material type. + + **Key Features:** + - Returns direct children of a parent category identified by slug + - Filters by both sect type and source type for precise navigation + - Useful for filtering categories by source material (Quran, Hadith, History, Fatwa, Quote) + - Provides hierarchical navigation with source-specific filtering + + **Usage:** + - Use this endpoint when you need to filter categories by source material type + - The `source_type` parameter allows filtering by: 'quran', 'hadith', 'history', 'fatwa', or 'quote' + - This is useful for building source-specific category navigation + - Combine with sect type to get categories for a specific sect and source combination + + **Source Types:** + - `quran`: Categories related to Quranic commentary and interpretation + - `hadith`: Categories containing prophetic traditions + - `history`: Historical accounts and narratives + - `fatwa`: Legal opinions and religious rulings + - `quote`: Quotations and sayings + + **Navigation Flow:** + 1. Start with root categories filtered by sect and source type + 2. Use the slug from a category to get its children (with same source type) + 3. Continue until you reach a category with `has_hadis: true` + 4. Use that category's ID to fetch hadis + """, + operation_summary="Get Category Children by Sect, Slug and Source", + operation_id="getCategoryChildrenBySectSlugAndSource", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'sect_type', + openapi.IN_PATH, + description="Type of Islamic sect. Must be 'shia' or 'sunni'. Ensures categories belong to the correct sect.", + type=openapi.TYPE_STRING, + enum=['shia', 'sunni'], + required=True, + example='shia' + ), + openapi.Parameter( + 'slug', + openapi.IN_PATH, + description="URL-friendly slug identifier of the parent category.", + type=openapi.TYPE_STRING, + required=True, + example='quran-commentary' + ), + openapi.Parameter( + 'source_type', + openapi.IN_PATH, + description="Type of source material. Filters children to only those matching this source type.", + type=openapi.TYPE_STRING, + enum=['quran', 'hadith', 'history', 'fatwa', 'quote'], + required=True, + example='quran' + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Paginated list of child categories filtered by sect type and source type", + examples={ + "application/json": { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "source_type": "quran", + "slug": "", + "sect_id": 20, + "sect_type": "shia", + "description": "Commentary and interpretation of the opening chapter of the Quran", + "children_count": 0, + "has_hadis": True, + "hadis_count": 10 + }, + { + "id": 331, + "title": "Толкование суры Аль-Бакара", + "source_type": "quran", + "slug": "-16", + "sect_id": 20, + "sect_type": "shia", + "description": "Detailed explanation of Surah Al-Baqarah verses and their meanings", + "children_count": 0, + "has_hadis": True, + "hadis_count": 10 + }, + { + "id": 332, + "title": "Толкование суры Аль Имран", + "source_type": "quran", + "slug": "-32", + "sect_id": 20, + "sect_type": "shia", + "description": "Interpretation of Surah Al-Imran with scholarly insights", + "children_count": 0, + "has_hadis": True, + "hadis_count": 10 + } + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Parent category not found or no children exist", + examples={ + "application/json": { + "detail": "No categories found matching the specified criteria." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + + +# Swagger documentation for HadisMainListView +hadis_main_list_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a comprehensive list of all Hadis (traditions) with advanced search and filtering capabilities. + + **Key Features:** + - Returns all active hadis entries with their complete information + - Supports full-text search across titles, narrator titles, Arabic text, and translations + - Filter by hadis status and category titles + - Includes complete category and status metadata in the response + - Supports pagination for large result sets + - Translations are automatically provided based on the Accept-Language header + + **Search Functionality:** + - Search parameter performs case-insensitive search across: + - Hadis titles (localized) + - Narrator titles (localized) + - Arabic text content + - Translation text (localized) + + **Filtering Options:** + - `status`: Filter by hadis status title (e.g., "authentic", "weak") + - `category`: Filter by category title (e.g., "prayer", "faith") + + **Pagination:** + - Use `page` parameter to navigate through pages (starts from 1) + - Use `page_size` parameter to control items per page + - Response includes `next` and `previous` URLs for navigation + + **Response Structure:** + - `count`: Total number of hadis matching the criteria (not just current page) + - `next`: URL for the next page (null if on last page) + - `previous`: URL for the previous page (null if on first page) + - `categories`: List of all available categories with localized titles + - `statuses`: List of all available hadis statuses with localized titles + - `results`: Array of hadis objects for the current page with full details including individual status + """, + operation_summary="List All Hadis with Search & Filters", + tags=['Dobodbi - Hadis'], + manual_parameters=[ + openapi.Parameter( + 'search', + openapi.IN_QUERY, + description="Search term to filter hadis. Searches across titles, narrator titles, Arabic text, and translations. Case-insensitive partial matching.", + type=openapi.TYPE_STRING, + required=False, + example="prayer" + ), + openapi.Parameter( + 'status', + openapi.IN_QUERY, + description="Filter hadis by status title. Case-insensitive partial matching.", + type=openapi.TYPE_STRING, + required=False, + example="authentic" + ), + openapi.Parameter( + 'category', + openapi.IN_QUERY, + description="Filter hadis by category title. Case-insensitive partial matching.", + type=openapi.TYPE_STRING, + required=False, + example="prayer" + ), + openapi.Parameter( + 'Accept-Language', + openapi.IN_HEADER, + description="Language code for localized content. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu), 'ru' (Russian). Defaults to 'en' if not specified.", + type=openapi.TYPE_STRING, + required=False, + default='en', + enum=['en', 'fa', 'ar', 'ur', 'ru'] + ), + openapi.Parameter( + 'page', + openapi.IN_QUERY, + description="Page number for pagination. Starts from 1. If not provided, returns the first page.", + type=openapi.TYPE_INTEGER, + required=False, + example=1 + ), + openapi.Parameter( + 'page_size', + openapi.IN_QUERY, + description="Number of items per page. If not provided, uses the default page size.", + type=openapi.TYPE_INTEGER, + required=False, + example=20 + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved list of hadis with search results, categories, and statuses", + examples={ + "application/json": { + "count": 150, + "next": "http://example.com/api/hadis/arguments/?page=2", + "previous": None, + "results": [ + { + "id": 1, + "number": 1, + "slug": "intention-hadith", + "title": "The Opening Hadith on Intention", + "title_narrator": "From Umar ibn al-Khattab", + "text": "إنما الأعمال بالنيات وإنما لكل امرئ ما نوى", + "translation": "Actions are but by intention, and every man shall have only what he intended", + "category": { + "id": 1, + "title": "Faith Fundamentals", + "slug": "faith-fundamentals", + "source_type": "hadith", + "sect_type": "sunni" + }, + "status": { + "id": 1, + "title": "Authentic (Sahih)", + "color": "green" + }, + "share_link": "http://example.com/hadis/1" + }, + { + "id": 2, + "number": 2, + "slug": "prayer-timing", + "title": "Prayer Timing Importance", + "title_narrator": "From Abdullah ibn Mas'ud", + "text": "الصلاة على وقتها فريضة", + "translation": "Prayer at its proper time is obligatory", + "category": { + "id": 2, + "title": "Prayer Rites", + "slug": "prayer-rites", + "source_type": "hadith", + "sect_type": "sunni" + }, + "status": { + "id": 1, + "title": "Authentic (Sahih)", + "color": "green" + }, + "share_link": "http://example.com/hadis/2" + } + ] + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) + +# # Swagger documentation for ContentReleaseSyncView +# content_release_sync_swagger = swagger_auto_schema( +# operation_description="Get list of all active content releases for offline mode sync", +# operation_summary="List Content Releases", +# tags=[ +# 'Hadis' +# ], +# responses={ +# status.HTTP_200_OK: openapi.Response( +# description="List of active content releases with pagination info", +# examples={ +# "application/json": { +# "count": 2, +# "results": [ +# { +# "id": 1, +# "version_name": "v1.2 - Muharram Update", +# "published_at": "2024-01-15T10:30:00Z", +# "description": "New hadis content added for Muharram month", +# "is_active": True +# }, +# { +# "id": 2, +# "version_name": "v1.1 - Initial Release", +# "published_at": "2024-01-01T00:00:00Z", +# "description": "Initial release of the hadis database", +# "is_active": True +# } +# ] +# } +# } +# ), +# status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( +# description="Internal server error" +# ) +# } +# ) + + +# Swagger documentation for ContentReleaseSyncView +content_release_sync_swagger = swagger_auto_schema( + operation_description='Get the latest active content release for offline mode sync', + operation_summary='Get Latest Content Release', + tags=['Dobodbi - Hadis'], + responses={ + status.HTTP_200_OK: openapi.Response( + description='Latest active content release object', + examples={ + 'application/json': { + 'id': 1, + 'version_name': 'v1.2 - Muharram Update', + 'published_at': '2024-01-15T10:30:00Z', + 'description': 'New hadis content added for Muharram month', + 'is_active': True + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description='Internal server error' + ) + } +) diff --git a/apps/hadis/fixtures/categories_backup.json b/apps/hadis/fixtures/categories_backup.json new file mode 100644 index 0000000..fb3b363 --- /dev/null +++ b/apps/hadis/fixtures/categories_backup.json @@ -0,0 +1,1565 @@ +[ +{ + "model": "hadis.hadiscategory", + "pk": 324, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование Корана', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-3", + "lft": 1, + "rght": 8, + "tree_id": 1, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 325, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты постановлений', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "hadis/xmind_files/category_325_Аяты_постановлений.xmind", + "slug": "-19", + "lft": 1, + "rght": 8, + "tree_id": 2, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 326, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Коранические истории', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "hadis/xmind_files/category_326_Коранические_истории.xmind", + "slug": "-34", + "lft": 1, + "rght": 6, + "tree_id": 3, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 327, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Достоинства сур', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 4, + "xmind_file": "hadis/xmind_files/category_327_Достоинства_сур.xmind", + "slug": "-46", + "lft": 1, + "rght": 2, + "tree_id": 4, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 328, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Чудеса Корана', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 5, + "xmind_file": "hadis/xmind_files/category_328_Чудеса_Корана.xmind", + "slug": "-48", + "lft": 1, + "rght": 2, + "tree_id": 5, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 329, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Коранические науки', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 6, + "xmind_file": "", + "slug": "-55", + "lft": 1, + "rght": 2, + "tree_id": 6, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 330, + "fields": { + "parent": 324, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование суры Аль-Фатиха', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "", + "lft": 2, + "rght": 3, + "tree_id": 1, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 331, + "fields": { + "parent": 324, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование суры Аль-Бакара', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-16", + "lft": 4, + "rght": 5, + "tree_id": 1, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 332, + "fields": { + "parent": 324, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование суры Аль Имран', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-32", + "lft": 6, + "rght": 7, + "tree_id": 1, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 333, + "fields": { + "parent": 325, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты о молитве', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-15", + "lft": 2, + "rght": 3, + "tree_id": 2, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 334, + "fields": { + "parent": 325, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты о посте', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-22", + "lft": 4, + "rght": 5, + "tree_id": 2, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 335, + "fields": { + "parent": 325, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты о закяте', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-33", + "lft": 6, + "rght": 7, + "tree_id": 2, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 336, + "fields": { + "parent": 326, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Истории пророков', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-14", + "lft": 2, + "rght": 3, + "tree_id": 3, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 337, + "fields": { + "parent": 326, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Истории праведников', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-21", + "lft": 4, + "rght": 5, + "tree_id": 3, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 338, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга очищения', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "hadis/xmind_files/category_338_Книга_очищения.xmind", + "slug": "-1", + "lft": 1, + "rght": 8, + "tree_id": 7, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 339, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга молитвы', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "hadis/xmind_files/category_339_Книга_молитвы.xmind", + "slug": "-8", + "lft": 1, + "rght": 8, + "tree_id": 8, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 340, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга поста', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "hadis/xmind_files/category_340_Книга_поста.xmind", + "slug": "-36", + "lft": 1, + "rght": 2, + "tree_id": 9, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 341, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга хаджа', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 4, + "xmind_file": "hadis/xmind_files/category_341_Книга_хаджа.xmind", + "slug": "-47", + "lft": 1, + "rght": 2, + "tree_id": 10, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 342, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга закята', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 5, + "xmind_file": "", + "slug": "-51", + "lft": 1, + "rght": 2, + "tree_id": 11, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 343, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга нравственности', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 6, + "xmind_file": "hadis/xmind_files/category_343_Книга_нравственности.xmind", + "slug": "-54", + "lft": 1, + "rght": 6, + "tree_id": 12, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 344, + "fields": { + "parent": 338, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Омовение', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-9", + "lft": 2, + "rght": 3, + "tree_id": 7, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 345, + "fields": { + "parent": 338, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Полное омовение', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-20", + "lft": 4, + "rght": 5, + "tree_id": 7, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 346, + "fields": { + "parent": 338, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Сухое омовение', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-35", + "lft": 6, + "rght": 7, + "tree_id": 7, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 347, + "fields": { + "parent": 339, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Времена молитв', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-2", + "lft": 2, + "rght": 3, + "tree_id": 8, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 348, + "fields": { + "parent": 339, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Направление киблы', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-25", + "lft": 4, + "rght": 5, + "tree_id": 8, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 349, + "fields": { + "parent": 339, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Коллективная молитва', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-41", + "lft": 6, + "rght": 7, + "tree_id": 8, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 350, + "fields": { + "parent": 343, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Терпение и благодарность', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-7", + "lft": 2, + "rght": 3, + "tree_id": 12, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 351, + "fields": { + "parent": 343, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Справедливость и честность', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-28", + "lft": 4, + "rght": 5, + "tree_id": 12, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 352, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование Корана', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-13", + "lft": 1, + "rght": 8, + "tree_id": 13, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 353, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты постановлений', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-17", + "lft": 1, + "rght": 8, + "tree_id": 14, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 354, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Коранические истории', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "hadis/xmind_files/category_354_Коранические_истории.xmind", + "slug": "-39", + "lft": 1, + "rght": 6, + "tree_id": 15, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 355, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Достоинства сур', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 4, + "xmind_file": "hadis/xmind_files/category_355_Достоинства_сур.xmind", + "slug": "-43", + "lft": 1, + "rght": 2, + "tree_id": 16, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 356, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Чудеса Корана', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 5, + "xmind_file": "hadis/xmind_files/category_356_Чудеса_Корана.xmind", + "slug": "-49", + "lft": 1, + "rght": 2, + "tree_id": 17, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 357, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Коранические науки', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 6, + "xmind_file": "", + "slug": "-50", + "lft": 1, + "rght": 2, + "tree_id": 18, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 358, + "fields": { + "parent": 352, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование суры Аль-Фатиха', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-12", + "lft": 2, + "rght": 3, + "tree_id": 13, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 359, + "fields": { + "parent": 352, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование суры Аль-Бакара', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-29", + "lft": 4, + "rght": 5, + "tree_id": 13, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 360, + "fields": { + "parent": 352, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Толкование суры Аль Имран', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-37", + "lft": 6, + "rght": 7, + "tree_id": 13, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 361, + "fields": { + "parent": 353, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты о молитве', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-5", + "lft": 2, + "rght": 3, + "tree_id": 14, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 362, + "fields": { + "parent": 353, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты о посте', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-27", + "lft": 4, + "rght": 5, + "tree_id": 14, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 363, + "fields": { + "parent": 353, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Аяты о закяте', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-38", + "lft": 6, + "rght": 7, + "tree_id": 14, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 364, + "fields": { + "parent": 354, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Истории пророков', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-4", + "lft": 2, + "rght": 3, + "tree_id": 15, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 365, + "fields": { + "parent": 354, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "[{'text': 'Истории праведников', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-23", + "lft": 4, + "rght": 5, + "tree_id": 15, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 366, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга очищения', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "hadis/xmind_files/category_366_Книга_очищения.xmind", + "slug": "-10", + "lft": 1, + "rght": 8, + "tree_id": 19, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 367, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга молитвы', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "hadis/xmind_files/category_367_Книга_молитвы.xmind", + "slug": "-18", + "lft": 1, + "rght": 8, + "tree_id": 20, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 368, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга поста', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "hadis/xmind_files/category_368_Книга_поста.xmind", + "slug": "-42", + "lft": 1, + "rght": 2, + "tree_id": 21, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 369, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга хаджа', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 4, + "xmind_file": "", + "slug": "-45", + "lft": 1, + "rght": 2, + "tree_id": 22, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 370, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга закята', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 5, + "xmind_file": "", + "slug": "-44", + "lft": 1, + "rght": 2, + "tree_id": 23, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 371, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Книга нравственности', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 6, + "xmind_file": "", + "slug": "-53", + "lft": 1, + "rght": 6, + "tree_id": 24, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 372, + "fields": { + "parent": 366, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Омовение', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-11", + "lft": 2, + "rght": 3, + "tree_id": 19, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 373, + "fields": { + "parent": 366, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Полное омовение', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-31", + "lft": 4, + "rght": 5, + "tree_id": 19, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 374, + "fields": { + "parent": 366, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Сухое омовение', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-26", + "lft": 6, + "rght": 7, + "tree_id": 19, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 375, + "fields": { + "parent": 367, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Времена молитв', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-6", + "lft": 2, + "rght": 3, + "tree_id": 20, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 376, + "fields": { + "parent": 367, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Направление киблы', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-30", + "lft": 4, + "rght": 5, + "tree_id": 20, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 377, + "fields": { + "parent": 367, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Коллективная молитва', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 3, + "xmind_file": "", + "slug": "-40", + "lft": 6, + "rght": 7, + "tree_id": 20, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 378, + "fields": { + "parent": 371, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Терпение и благодарность', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 1, + "xmind_file": "", + "slug": "-56", + "lft": 2, + "rght": 3, + "tree_id": 24, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 379, + "fields": { + "parent": 371, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "[{'text': 'Справедливость и честность', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": null, + "order": 2, + "xmind_file": "", + "slug": "-24", + "lft": 4, + "rght": 5, + "tree_id": 24, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 402, + "fields": { + "parent": null, + "sect": 21, + "source_type": "history", + "title": [ + { + "text": "[{'text': 'History of Islam', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "High-level historical themes related to early Islamic history.", + "order": 1, + "xmind_file": "", + "slug": "history-of-islam", + "lft": 1, + "rght": 6, + "tree_id": 25, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 403, + "fields": { + "parent": 402, + "sect": 21, + "source_type": "history", + "title": [ + { + "text": "[{'text': 'Early Caliphate', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Events and reports from the period of the first caliphs.", + "order": 1, + "xmind_file": "", + "slug": "early-caliphate", + "lft": 2, + "rght": 3, + "tree_id": 25, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 404, + "fields": { + "parent": 402, + "sect": 21, + "source_type": "history", + "title": [ + { + "text": "[{'text': 'Battles and Expeditions', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Key battles and expeditions in early Islamic history.", + "order": 2, + "xmind_file": "", + "slug": "battles-and-expeditions", + "lft": 4, + "rght": 5, + "tree_id": 25, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 405, + "fields": { + "parent": null, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "[{'text': 'Contemporary Fatwas', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Modern juristic responses to contemporary questions.", + "order": 1, + "xmind_file": "", + "slug": "contemporary-fatwas", + "lft": 1, + "rght": 6, + "tree_id": 26, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 406, + "fields": { + "parent": 405, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "[{'text': 'Worship and Rituals', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Fatwas about prayer, fasting and other acts of worship.", + "order": 1, + "xmind_file": "", + "slug": "worship-and-rituals", + "lft": 2, + "rght": 3, + "tree_id": 26, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 407, + "fields": { + "parent": 405, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "[{'text': 'Family Issues', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Fatwas regarding marriage, divorce and family obligations.", + "order": 2, + "xmind_file": "", + "slug": "family-issues", + "lft": 4, + "rght": 5, + "tree_id": 26, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 408, + "fields": { + "parent": null, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "[{'text': 'Financial Fatwas', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Juristic rulings about trade, contracts and modern finance.", + "order": 2, + "xmind_file": "", + "slug": "financial-fatwas", + "lft": 1, + "rght": 4, + "tree_id": 27, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 409, + "fields": { + "parent": 408, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "[{'text': 'Trade and Contracts', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Fatwas related to buying, selling and contractual agreements.", + "order": 1, + "xmind_file": "", + "slug": "trade-and-contracts", + "lft": 2, + "rght": 3, + "tree_id": 27, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 410, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quote", + "title": [ + { + "text": "[{'text': 'Wisdom Quotes', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Short wise sayings and inspirational quotes.", + "order": 1, + "xmind_file": "", + "slug": "wisdom-quotes", + "lft": 1, + "rght": 6, + "tree_id": 28, + "level": 0 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 411, + "fields": { + "parent": 410, + "sect": 20, + "source_type": "quote", + "title": [ + { + "text": "[{'text': 'Short Wisdom', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Very short, memorable quotes on character and behavior.", + "order": 1, + "xmind_file": "", + "slug": "short-wisdom", + "lft": 2, + "rght": 3, + "tree_id": 28, + "level": 1 + } +}, +{ + "model": "hadis.hadiscategory", + "pk": 412, + "fields": { + "parent": 410, + "sect": 20, + "source_type": "quote", + "title": [ + { + "text": "[{'text': 'On Knowledge', 'language_code': 'en'}]", + "language_code": "en" + } + ], + "description": "Quotes emphasizing the virtue of knowledge and learning.", + "order": 2, + "xmind_file": "", + "slug": "on-knowledge", + "lft": 4, + "rght": 5, + "tree_id": 28, + "level": 1 + } +}, +{ + "model": "hadis.hadissect", + "pk": 20, + "fields": { + "sect_type": "shia", + "title": "Шииты-двунадесятники", + "description": null, + "is_active": true, + "order": 1 + } +}, +{ + "model": "hadis.hadissect", + "pk": 21, + "fields": { + "sect_type": "sunni", + "title": "Сунниты", + "description": null, + "is_active": true, + "order": 2 + } +} +] diff --git a/apps/hadis/fixtures/hadises1_backup.json b/apps/hadis/fixtures/hadises1_backup.json new file mode 100644 index 0000000..fecc64d --- /dev/null +++ b/apps/hadis/fixtures/hadises1_backup.json @@ -0,0 +1,2075 @@ +[ +{ + "model": "hadis.hadiscollection", + "pk": 1, + "fields": { + "title": "Purification & Prayer", + "slug": "purification-prayer", + "summary": "Collection of ahadith related to purification (Wudu, Ghusl) and prayer rituals.", + "status": true, + "order": 1, + "thumbnail": null, + "created_at": "2025-12-16T16:14:31.011", + "updated_at": "2025-12-16T16:14:31.011" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 2, + "fields": { + "title": "Zakat & Charity", + "slug": "zakat-charity", + "summary": "Ahadith discussing the obligation of zakat and charitable giving in Islam.", + "status": true, + "order": 2, + "thumbnail": null, + "created_at": "2025-12-16T16:14:31.496", + "updated_at": "2025-12-16T16:14:31.496" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 3, + "fields": { + "title": "Fasting & Ramadan", + "slug": "fasting-ramadan", + "summary": "Collection of authentic ahadith about fasting, its virtues, and Ramadan practices.", + "status": true, + "order": 3, + "thumbnail": null, + "created_at": "2025-12-16T16:14:31.984", + "updated_at": "2025-12-16T16:14:31.984" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 4, + "fields": { + "title": "Hajj & Umrah", + "slug": "hajj-umrah", + "summary": "Ahadith related to the pilgrimage to Mecca and the spiritual practices involved.", + "status": true, + "order": 4, + "thumbnail": null, + "created_at": "2025-12-16T16:14:32.471", + "updated_at": "2025-12-16T16:14:32.471" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 5, + "fields": { + "title": "Ethics & Morality", + "slug": "ethics-morality", + "summary": "Ahadith emphasizing Islamic ethics, good character, and moral conduct.", + "status": true, + "order": 5, + "thumbnail": null, + "created_at": "2025-12-16T16:14:32.956", + "updated_at": "2025-12-16T16:14:32.956" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 6, + "fields": { + "title": "Knowledge & Learning", + "slug": "knowledge-learning", + "summary": "Collection emphasizing the importance of seeking knowledge in Islam.", + "status": true, + "order": 6, + "thumbnail": null, + "created_at": "2025-12-16T16:14:33.438", + "updated_at": "2025-12-16T16:14:33.438" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 7, + "fields": { + "title": "Family & Relations", + "slug": "family-relations", + "summary": "Ahadith about family relationships, rights of parents, spouses, and children.", + "status": true, + "order": 7, + "thumbnail": null, + "created_at": "2025-12-16T16:14:33.925", + "updated_at": "2025-12-16T16:14:33.925" + } +}, +{ + "model": "hadis.hadiscollection", + "pk": 8, + "fields": { + "title": "Business & Trade", + "slug": "business-trade", + "summary": "Ahadith related to ethical business practices and commerce in Islam.", + "status": true, + "order": 8, + "thumbnail": null, + "created_at": "2025-12-16T16:14:34.407", + "updated_at": "2025-12-16T16:14:34.407" + } +}, +{ + "model": "hadis.hadistag", + "pk": 507, + "fields": { + "title": "Поклонение", + "status": true, + "created_at": "2025-07-04T18:31:36.034", + "updated_at": "2025-07-04T18:31:36.034" + } +}, +{ + "model": "hadis.hadistag", + "pk": 508, + "fields": { + "title": "Молитва", + "status": true, + "created_at": "2025-07-04T18:31:36.722", + "updated_at": "2025-07-04T18:31:36.722" + } +}, +{ + "model": "hadis.hadistag", + "pk": 509, + "fields": { + "title": "Пост", + "status": true, + "created_at": "2025-07-04T18:31:37.537", + "updated_at": "2025-07-04T18:31:37.537" + } +}, +{ + "model": "hadis.hadistag", + "pk": 510, + "fields": { + "title": "Хадж", + "status": true, + "created_at": "2025-07-04T18:31:38.199", + "updated_at": "2025-07-04T18:31:38.199" + } +}, +{ + "model": "hadis.hadistag", + "pk": 511, + "fields": { + "title": "Закят", + "status": true, + "created_at": "2025-07-04T18:31:38.883", + "updated_at": "2025-07-04T18:31:38.883" + } +}, +{ + "model": "hadis.hadistag", + "pk": 512, + "fields": { + "title": "Хумс", + "status": true, + "created_at": "2025-07-04T18:31:40.144", + "updated_at": "2025-07-04T18:31:40.144" + } +}, +{ + "model": "hadis.hadistag", + "pk": 513, + "fields": { + "title": "Нравственность", + "status": true, + "created_at": "2025-07-04T18:31:40.888", + "updated_at": "2025-07-04T18:31:40.888" + } +}, +{ + "model": "hadis.hadistag", + "pk": 514, + "fields": { + "title": "Терпение", + "status": true, + "created_at": "2025-07-04T18:31:41.659", + "updated_at": "2025-07-04T18:31:41.659" + } +}, +{ + "model": "hadis.hadistag", + "pk": 515, + "fields": { + "title": "Благодарность", + "status": true, + "created_at": "2025-07-04T18:31:42.487", + "updated_at": "2025-07-04T18:31:42.487" + } +}, +{ + "model": "hadis.hadistag", + "pk": 516, + "fields": { + "title": "Доверие", + "status": true, + "created_at": "2025-07-04T18:31:43.123", + "updated_at": "2025-07-04T18:31:43.123" + } +}, +{ + "model": "hadis.hadistag", + "pk": 517, + "fields": { + "title": "Богобоязненность", + "status": true, + "created_at": "2025-07-04T18:31:44.439", + "updated_at": "2025-07-04T18:31:44.439" + } +}, +{ + "model": "hadis.hadistag", + "pk": 518, + "fields": { + "title": "Справедливость", + "status": true, + "created_at": "2025-07-04T18:31:45.106", + "updated_at": "2025-07-04T18:31:45.106" + } +}, +{ + "model": "hadis.hadistag", + "pk": 519, + "fields": { + "title": "Фикх", + "status": true, + "created_at": "2025-07-04T18:31:45.785", + "updated_at": "2025-07-04T18:31:45.785" + } +}, +{ + "model": "hadis.hadistag", + "pk": 520, + "fields": { + "title": "Постановления", + "status": true, + "created_at": "2025-07-04T18:31:46.493", + "updated_at": "2025-07-04T18:31:46.493" + } +}, +{ + "model": "hadis.hadistag", + "pk": 521, + "fields": { + "title": "Халяль", + "status": true, + "created_at": "2025-07-04T18:31:47.278", + "updated_at": "2025-07-04T18:31:47.278" + } +}, +{ + "model": "hadis.hadistag", + "pk": 522, + "fields": { + "title": "Харам", + "status": true, + "created_at": "2025-07-04T18:31:48.378", + "updated_at": "2025-07-04T18:31:48.378" + } +}, +{ + "model": "hadis.hadistag", + "pk": 523, + "fields": { + "title": "Мустахаб", + "status": true, + "created_at": "2025-07-04T18:31:49.044", + "updated_at": "2025-07-04T18:31:49.044" + } +}, +{ + "model": "hadis.hadistag", + "pk": 524, + "fields": { + "title": "Макрух", + "status": true, + "created_at": "2025-07-04T18:31:49.722", + "updated_at": "2025-07-04T18:31:49.722" + } +}, +{ + "model": "hadis.hadistag", + "pk": 525, + "fields": { + "title": "Толкование", + "status": true, + "created_at": "2025-07-04T18:31:50.400", + "updated_at": "2025-07-04T18:31:50.400" + } +}, +{ + "model": "hadis.hadistag", + "pk": 526, + "fields": { + "title": "Коран", + "status": true, + "created_at": "2025-07-04T18:31:51.220", + "updated_at": "2025-07-04T18:31:51.220" + } +}, +{ + "model": "hadis.hadistag", + "pk": 527, + "fields": { + "title": "Аяты", + "status": true, + "created_at": "2025-07-04T18:31:52.341", + "updated_at": "2025-07-04T18:31:52.341" + } +}, +{ + "model": "hadis.hadistag", + "pk": 528, + "fields": { + "title": "Сура", + "status": true, + "created_at": "2025-07-04T18:31:53.151", + "updated_at": "2025-07-04T18:31:53.151" + } +}, +{ + "model": "hadis.hadistag", + "pk": 529, + "fields": { + "title": "Чтение", + "status": true, + "created_at": "2025-07-04T18:31:53.874", + "updated_at": "2025-07-04T18:31:53.874" + } +}, +{ + "model": "hadis.hadistag", + "pk": 530, + "fields": { + "title": "Имамат", + "status": true, + "created_at": "2025-07-04T18:31:54.578", + "updated_at": "2025-07-04T18:31:54.578" + } +}, +{ + "model": "hadis.hadistag", + "pk": 531, + "fields": { + "title": "Власть", + "status": true, + "created_at": "2025-07-04T18:31:55.346", + "updated_at": "2025-07-04T18:31:55.346" + } +}, +{ + "model": "hadis.hadistag", + "pk": 532, + "fields": { + "title": "Непорочные", + "status": true, + "created_at": "2025-07-04T18:31:56.494", + "updated_at": "2025-07-04T18:31:56.494" + } +}, +{ + "model": "hadis.hadistag", + "pk": 533, + "fields": { + "title": "Семья Пророка", + "status": true, + "created_at": "2025-07-04T18:31:57.216", + "updated_at": "2025-07-04T18:31:57.216" + } +}, +{ + "model": "hadis.hadistag", + "pk": 534, + "fields": { + "title": "Мольба", + "status": true, + "created_at": "2025-07-04T18:31:57.983", + "updated_at": "2025-07-04T18:31:57.983" + } +}, +{ + "model": "hadis.hadistag", + "pk": 535, + "fields": { + "title": "Поминание", + "status": true, + "created_at": "2025-07-04T18:31:58.671", + "updated_at": "2025-07-04T18:31:58.671" + } +}, +{ + "model": "hadis.hadistag", + "pk": 536, + "fields": { + "title": "Прощение", + "status": true, + "created_at": "2025-07-04T18:31:59.420", + "updated_at": "2025-07-04T18:31:59.420" + } +}, +{ + "model": "hadis.hadistag", + "pk": 537, + "fields": { + "title": "Восхваление", + "status": true, + "created_at": "2025-07-04T18:32:00.699", + "updated_at": "2025-07-04T18:32:00.699" + } +}, +{ + "model": "hadis.hadistag", + "pk": 538, + "fields": { + "title": "Единобожие", + "status": true, + "created_at": "2025-07-04T18:32:01.440", + "updated_at": "2025-07-04T18:32:01.440" + } +}, +{ + "model": "hadis.hadistag", + "pk": 539, + "fields": { + "title": "history", + "status": true, + "created_at": "2025-12-17T08:46:13.910", + "updated_at": "2025-12-17T08:46:13.910" + } +}, +{ + "model": "hadis.hadistag", + "pk": 540, + "fields": { + "title": "biography", + "status": true, + "created_at": "2025-12-17T08:46:14.276", + "updated_at": "2025-12-17T08:46:14.276" + } +}, +{ + "model": "hadis.hadistag", + "pk": 541, + "fields": { + "title": "battle", + "status": true, + "created_at": "2025-12-17T08:46:14.637", + "updated_at": "2025-12-17T08:46:14.637" + } +}, +{ + "model": "hadis.hadistag", + "pk": 542, + "fields": { + "title": "ethics", + "status": true, + "created_at": "2025-12-17T08:46:15.011", + "updated_at": "2025-12-17T08:46:15.011" + } +}, +{ + "model": "hadis.hadistag", + "pk": 543, + "fields": { + "title": "jurisprudence", + "status": true, + "created_at": "2025-12-17T08:46:15.371", + "updated_at": "2025-12-17T08:46:15.371" + } +}, +{ + "model": "hadis.hadistag", + "pk": 544, + "fields": { + "title": "family", + "status": true, + "created_at": "2025-12-17T08:46:15.728", + "updated_at": "2025-12-17T08:46:15.728" + } +}, +{ + "model": "hadis.hadistag", + "pk": 545, + "fields": { + "title": "wisdom", + "status": true, + "created_at": "2025-12-17T08:46:16.086", + "updated_at": "2025-12-17T08:46:16.087" + } +}, +{ + "model": "hadis.hadistag", + "pk": 546, + "fields": { + "title": "short quote", + "status": true, + "created_at": "2025-12-17T08:46:16.446", + "updated_at": "2025-12-17T08:46:16.446" + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 126, + "fields": { + "title": "Достоверный", + "color": "green", + "order": 1 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 127, + "fields": { + "title": "Хороший", + "color": "blue", + "order": 2 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 128, + "fields": { + "title": "Слабый", + "color": "yellow", + "order": 3 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 129, + "fields": { + "title": "Выдуманный", + "color": "red", + "order": 4 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 130, + "fields": { + "title": "Прерванный", + "color": "orange", + "order": 5 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 131, + "fields": { + "title": "Разорванный", + "color": "purple", + "order": 6 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 132, + "fields": { + "title": "Неизвестный", + "color": "gray", + "order": 7 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 133, + "fields": { + "title": "Authentic / Accepted", + "color": "green", + "order": 1 + } +}, +{ + "model": "hadis.hadisstatus", + "pk": 134, + "fields": { + "title": "Weak / Needs Review", + "color": "yellow", + "order": 2 + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2193, + "fields": { + "hadis": 1800, + "book_reference": null, + "created_at": "2025-07-04T18:33:06.176", + "description": "Volume 4, Page 813, Hadis #829" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2194, + "fields": { + "hadis": 1801, + "book_reference": null, + "created_at": "2025-07-04T18:33:08.217", + "description": "Volume 4, Page 453, Hadis #6769" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2195, + "fields": { + "hadis": 1802, + "book_reference": null, + "created_at": "2025-07-04T18:33:10.247", + "description": "Volume 2, Page 221, Hadis #2454" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2196, + "fields": { + "hadis": 1803, + "book_reference": null, + "created_at": "2025-07-04T18:33:12.158", + "description": "Volume 2, Page 457, Hadis #8423" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2197, + "fields": { + "hadis": 1804, + "book_reference": null, + "created_at": "2025-07-04T18:33:14.349", + "description": "Volume 1, Page 318, Hadis #7345" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2198, + "fields": { + "hadis": 1805, + "book_reference": null, + "created_at": "2025-07-04T18:33:16.555", + "description": "Volume 2, Page 971, Hadis #837" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2199, + "fields": { + "hadis": 1806, + "book_reference": null, + "created_at": "2025-07-04T18:33:18.660", + "description": "Volume 5, Page 331, Hadis #5908" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2200, + "fields": { + "hadis": 1807, + "book_reference": null, + "created_at": "2025-07-04T18:33:20.684", + "description": "Volume 5, Page 45, Hadis #5691" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2201, + "fields": { + "hadis": 1808, + "book_reference": null, + "created_at": "2025-07-04T18:33:22.687", + "description": "Volume 4, Page 520, Hadis #8315" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2202, + "fields": { + "hadis": 1809, + "book_reference": null, + "created_at": "2025-07-04T18:33:24.657", + "description": "Volume 1, Page 859, Hadis #4915" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2203, + "fields": { + "hadis": 1810, + "book_reference": null, + "created_at": "2025-07-04T18:33:26.871", + "description": "Volume 1, Page 67, Hadis #8829" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2204, + "fields": { + "hadis": 1811, + "book_reference": null, + "created_at": "2025-07-04T18:33:29.044", + "description": "Volume 2, Page 146, Hadis #8238" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2205, + "fields": { + "hadis": 1812, + "book_reference": null, + "created_at": "2025-07-04T18:33:31.268", + "description": "Volume 5, Page 638, Hadis #6959" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2206, + "fields": { + "hadis": 1813, + "book_reference": null, + "created_at": "2025-07-04T18:33:33.240", + "description": "Volume 2, Page 459, Hadis #2809" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2207, + "fields": { + "hadis": 1814, + "book_reference": null, + "created_at": "2025-07-04T18:33:35.361", + "description": "Volume 5, Page 487, Hadis #9558" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2208, + "fields": { + "hadis": 1815, + "book_reference": null, + "created_at": "2025-07-04T18:33:37.960", + "description": "Volume 4, Page 58, Hadis #8090" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2209, + "fields": { + "hadis": 1816, + "book_reference": null, + "created_at": "2025-07-04T18:33:40.074", + "description": "Volume 4, Page 172, Hadis #198" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2210, + "fields": { + "hadis": 1817, + "book_reference": null, + "created_at": "2025-07-04T18:33:42.060", + "description": "Volume 5, Page 414, Hadis #9060" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2211, + "fields": { + "hadis": 1818, + "book_reference": null, + "created_at": "2025-07-04T18:33:44.508", + "description": "Volume 1, Page 376, Hadis #9644" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2212, + "fields": { + "hadis": 1819, + "book_reference": null, + "created_at": "2025-07-04T18:33:46.599", + "description": "Volume 2, Page 952, Hadis #520" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2213, + "fields": { + "hadis": 1820, + "book_reference": null, + "created_at": "2025-07-04T18:33:48.637", + "description": "Volume 5, Page 905, Hadis #51" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2214, + "fields": { + "hadis": 1821, + "book_reference": null, + "created_at": "2025-07-04T18:33:50.508", + "description": "Volume 2, Page 759, Hadis #2569" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2215, + "fields": { + "hadis": 1822, + "book_reference": null, + "created_at": "2025-07-04T18:33:52.494", + "description": "Volume 1, Page 712, Hadis #3339" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2216, + "fields": { + "hadis": 1823, + "book_reference": null, + "created_at": "2025-07-04T18:33:54.751", + "description": "Volume 4, Page 380, Hadis #7766" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2217, + "fields": { + "hadis": 1824, + "book_reference": null, + "created_at": "2025-07-04T18:33:57.092", + "description": "Volume 3, Page 744, Hadis #8171" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2218, + "fields": { + "hadis": 1825, + "book_reference": null, + "created_at": "2025-07-04T18:34:00.118", + "description": "Volume 5, Page 557, Hadis #9068" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2219, + "fields": { + "hadis": 1826, + "book_reference": null, + "created_at": "2025-07-04T18:34:02.226", + "description": "Volume 2, Page 375, Hadis #5010" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2220, + "fields": { + "hadis": 1827, + "book_reference": null, + "created_at": "2025-07-04T18:34:06.084", + "description": "Volume 2, Page 150, Hadis #8150" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2221, + "fields": { + "hadis": 1828, + "book_reference": null, + "created_at": "2025-07-04T18:34:08.819", + "description": "Volume 5, Page 184, Hadis #9415" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2222, + "fields": { + "hadis": 1829, + "book_reference": null, + "created_at": "2025-07-04T18:34:10.833", + "description": "Volume 4, Page 64, Hadis #1556" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2223, + "fields": { + "hadis": 1830, + "book_reference": null, + "created_at": "2025-07-04T18:34:12.834", + "description": "Volume 2, Page 392, Hadis #3772" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2224, + "fields": { + "hadis": 1831, + "book_reference": null, + "created_at": "2025-07-04T18:34:15.679", + "description": "Volume 4, Page 154, Hadis #642" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2225, + "fields": { + "hadis": 1832, + "book_reference": null, + "created_at": "2025-07-04T18:34:18.163", + "description": "Volume 3, Page 403, Hadis #5230" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2226, + "fields": { + "hadis": 1833, + "book_reference": null, + "created_at": "2025-07-04T18:34:22.029", + "description": "Volume 2, Page 825, Hadis #5258" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2227, + "fields": { + "hadis": 1834, + "book_reference": null, + "created_at": "2025-07-04T18:34:25.991", + "description": "Volume 1, Page 62, Hadis #1978" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2228, + "fields": { + "hadis": 1835, + "book_reference": null, + "created_at": "2025-07-04T18:34:28.057", + "description": "Volume 5, Page 595, Hadis #5277" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2229, + "fields": { + "hadis": 1836, + "book_reference": null, + "created_at": "2025-07-04T18:34:30.065", + "description": "Volume 2, Page 102, Hadis #7664" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2230, + "fields": { + "hadis": 1837, + "book_reference": null, + "created_at": "2025-07-04T18:34:32.218", + "description": "Volume 1, Page 788, Hadis #2558" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2231, + "fields": { + "hadis": 1838, + "book_reference": null, + "created_at": "2025-07-04T18:34:34.547", + "description": "Volume 2, Page 177, Hadis #3176" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2232, + "fields": { + "hadis": 1839, + "book_reference": null, + "created_at": "2025-07-04T18:34:36.889", + "description": "Volume 5, Page 377, Hadis #2332" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2233, + "fields": { + "hadis": 1840, + "book_reference": null, + "created_at": "2025-07-04T18:34:41.010", + "description": "Volume 4, Page 523, Hadis #7037" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2234, + "fields": { + "hadis": 1841, + "book_reference": null, + "created_at": "2025-07-04T18:34:50.251", + "description": "Volume 3, Page 137, Hadis #4052" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2235, + "fields": { + "hadis": 1842, + "book_reference": null, + "created_at": "2025-07-04T18:34:52.824", + "description": "Volume 1, Page 766, Hadis #917" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2236, + "fields": { + "hadis": 1843, + "book_reference": null, + "created_at": "2025-07-04T18:34:55.189", + "description": "Volume 5, Page 213, Hadis #8199" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2237, + "fields": { + "hadis": 1844, + "book_reference": null, + "created_at": "2025-07-04T18:34:57.099", + "description": "Volume 2, Page 337, Hadis #7569" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2238, + "fields": { + "hadis": 1845, + "book_reference": null, + "created_at": "2025-07-04T18:34:58.996", + "description": "Volume 2, Page 29, Hadis #2108" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2239, + "fields": { + "hadis": 1846, + "book_reference": null, + "created_at": "2025-07-04T18:35:00.923", + "description": "Volume 1, Page 35, Hadis #6469" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2240, + "fields": { + "hadis": 1847, + "book_reference": null, + "created_at": "2025-07-04T18:35:02.820", + "description": "Volume 1, Page 300, Hadis #8301" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2241, + "fields": { + "hadis": 1848, + "book_reference": null, + "created_at": "2025-07-04T18:35:04.835", + "description": "Volume 5, Page 790, Hadis #9347" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2242, + "fields": { + "hadis": 1849, + "book_reference": null, + "created_at": "2025-07-04T18:35:06.665", + "description": "Volume 1, Page 6, Hadis #544" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2243, + "fields": { + "hadis": 1850, + "book_reference": null, + "created_at": "2025-07-04T18:35:08.599", + "description": "Volume 2, Page 834, Hadis #5069" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2244, + "fields": { + "hadis": 1851, + "book_reference": null, + "created_at": "2025-07-04T18:35:10.547", + "description": "Volume 4, Page 738, Hadis #351" + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2245, + "fields": { + "hadis": 1800, + "book_reference": 11, + "created_at": "2025-12-16T16:14:34.999", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2246, + "fields": { + "hadis": 1801, + "book_reference": 10, + "created_at": "2025-12-16T16:14:35.489", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2247, + "fields": { + "hadis": 1801, + "book_reference": 9, + "created_at": "2025-12-16T16:14:35.876", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2248, + "fields": { + "hadis": 1802, + "book_reference": 9, + "created_at": "2025-12-16T16:14:36.360", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2249, + "fields": { + "hadis": 1802, + "book_reference": 8, + "created_at": "2025-12-16T16:14:36.747", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2250, + "fields": { + "hadis": 1802, + "book_reference": 7, + "created_at": "2025-12-16T16:14:37.134", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2251, + "fields": { + "hadis": 1803, + "book_reference": 8, + "created_at": "2025-12-16T16:14:37.621", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2252, + "fields": { + "hadis": 1804, + "book_reference": 7, + "created_at": "2025-12-16T16:14:38.110", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2253, + "fields": { + "hadis": 1804, + "book_reference": 4, + "created_at": "2025-12-16T16:14:38.499", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2254, + "fields": { + "hadis": 1805, + "book_reference": 4, + "created_at": "2025-12-16T16:14:38.990", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2255, + "fields": { + "hadis": 1805, + "book_reference": 2, + "created_at": "2025-12-16T16:14:39.377", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2256, + "fields": { + "hadis": 1806, + "book_reference": 2, + "created_at": "2025-12-16T16:14:39.864", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2257, + "fields": { + "hadis": 1807, + "book_reference": 11, + "created_at": "2025-12-16T16:14:40.349", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2258, + "fields": { + "hadis": 1807, + "book_reference": 10, + "created_at": "2025-12-16T16:14:40.740", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2259, + "fields": { + "hadis": 1808, + "book_reference": 10, + "created_at": "2025-12-16T16:14:41.229", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2260, + "fields": { + "hadis": 1808, + "book_reference": 9, + "created_at": "2025-12-16T16:14:41.616", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2261, + "fields": { + "hadis": 1808, + "book_reference": 8, + "created_at": "2025-12-16T16:14:42.004", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2262, + "fields": { + "hadis": 1809, + "book_reference": 9, + "created_at": "2025-12-16T16:14:42.490", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2263, + "fields": { + "hadis": 1810, + "book_reference": 8, + "created_at": "2025-12-16T16:14:42.978", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2264, + "fields": { + "hadis": 1810, + "book_reference": 7, + "created_at": "2025-12-16T16:14:43.364", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2265, + "fields": { + "hadis": 1811, + "book_reference": 7, + "created_at": "2025-12-16T16:14:43.849", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2266, + "fields": { + "hadis": 1811, + "book_reference": 4, + "created_at": "2025-12-16T16:14:44.238", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2267, + "fields": { + "hadis": 1811, + "book_reference": 2, + "created_at": "2025-12-16T16:14:44.626", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2268, + "fields": { + "hadis": 1812, + "book_reference": 4, + "created_at": "2025-12-16T16:14:45.112", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2269, + "fields": { + "hadis": 1813, + "book_reference": 2, + "created_at": "2025-12-16T16:14:45.593", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2270, + "fields": { + "hadis": 1814, + "book_reference": 11, + "created_at": "2025-12-16T16:14:46.076", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2271, + "fields": { + "hadis": 1814, + "book_reference": 10, + "created_at": "2025-12-16T16:14:46.461", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2272, + "fields": { + "hadis": 1814, + "book_reference": 9, + "created_at": "2025-12-16T16:14:46.845", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2273, + "fields": { + "hadis": 1815, + "book_reference": 10, + "created_at": "2025-12-16T16:14:47.329", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2274, + "fields": { + "hadis": 1816, + "book_reference": 9, + "created_at": "2025-12-16T16:14:47.816", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2275, + "fields": { + "hadis": 1816, + "book_reference": 8, + "created_at": "2025-12-16T16:14:48.201", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2276, + "fields": { + "hadis": 1817, + "book_reference": 8, + "created_at": "2025-12-16T16:14:48.684", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2277, + "fields": { + "hadis": 1817, + "book_reference": 7, + "created_at": "2025-12-16T16:14:49.073", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2278, + "fields": { + "hadis": 1817, + "book_reference": 4, + "created_at": "2025-12-16T16:14:49.459", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2279, + "fields": { + "hadis": 1818, + "book_reference": 7, + "created_at": "2025-12-16T16:14:49.946", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2280, + "fields": { + "hadis": 1819, + "book_reference": 4, + "created_at": "2025-12-16T16:14:50.429", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2281, + "fields": { + "hadis": 1819, + "book_reference": 2, + "created_at": "2025-12-16T16:14:50.813", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2282, + "fields": { + "hadis": 1820, + "book_reference": 2, + "created_at": "2025-12-16T16:14:51.297", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2283, + "fields": { + "hadis": 1821, + "book_reference": 11, + "created_at": "2025-12-16T16:14:51.783", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2284, + "fields": { + "hadis": 1822, + "book_reference": 10, + "created_at": "2025-12-16T16:14:52.267", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2285, + "fields": { + "hadis": 1822, + "book_reference": 9, + "created_at": "2025-12-16T16:14:52.654", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2286, + "fields": { + "hadis": 1823, + "book_reference": 9, + "created_at": "2025-12-16T16:14:53.138", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2287, + "fields": { + "hadis": 1823, + "book_reference": 8, + "created_at": "2025-12-16T16:14:53.523", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2288, + "fields": { + "hadis": 1823, + "book_reference": 7, + "created_at": "2025-12-16T16:14:53.907", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2289, + "fields": { + "hadis": 1824, + "book_reference": 8, + "created_at": "2025-12-16T16:14:54.393", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2290, + "fields": { + "hadis": 1825, + "book_reference": 7, + "created_at": "2025-12-16T16:14:54.879", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2291, + "fields": { + "hadis": 1825, + "book_reference": 4, + "created_at": "2025-12-16T16:14:55.264", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2292, + "fields": { + "hadis": 1826, + "book_reference": 4, + "created_at": "2025-12-16T16:14:55.748", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2293, + "fields": { + "hadis": 1826, + "book_reference": 2, + "created_at": "2025-12-16T16:14:56.133", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2294, + "fields": { + "hadis": 1827, + "book_reference": 2, + "created_at": "2025-12-16T16:14:56.620", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2295, + "fields": { + "hadis": 1828, + "book_reference": 11, + "created_at": "2025-12-16T16:14:57.105", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2296, + "fields": { + "hadis": 1828, + "book_reference": 10, + "created_at": "2025-12-16T16:14:57.490", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2297, + "fields": { + "hadis": 1829, + "book_reference": 10, + "created_at": "2025-12-16T16:14:57.976", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2298, + "fields": { + "hadis": 1829, + "book_reference": 9, + "created_at": "2025-12-16T16:14:58.361", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2299, + "fields": { + "hadis": 1829, + "book_reference": 8, + "created_at": "2025-12-16T16:14:58.751", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2300, + "fields": { + "hadis": 1830, + "book_reference": 9, + "created_at": "2025-12-16T16:14:59.236", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2301, + "fields": { + "hadis": 1831, + "book_reference": 8, + "created_at": "2025-12-16T16:14:59.720", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2302, + "fields": { + "hadis": 1831, + "book_reference": 7, + "created_at": "2025-12-16T16:15:00.106", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2303, + "fields": { + "hadis": 1832, + "book_reference": 7, + "created_at": "2025-12-16T16:15:00.592", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2304, + "fields": { + "hadis": 1832, + "book_reference": 4, + "created_at": "2025-12-16T16:15:00.979", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2305, + "fields": { + "hadis": 1832, + "book_reference": 2, + "created_at": "2025-12-16T16:15:01.365", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2306, + "fields": { + "hadis": 1833, + "book_reference": 4, + "created_at": "2025-12-16T16:15:01.855", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2307, + "fields": { + "hadis": 1834, + "book_reference": 2, + "created_at": "2025-12-16T16:15:02.347", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2308, + "fields": { + "hadis": 1835, + "book_reference": 11, + "created_at": "2025-12-16T16:15:02.834", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2309, + "fields": { + "hadis": 1835, + "book_reference": 10, + "created_at": "2025-12-16T16:15:03.224", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2310, + "fields": { + "hadis": 1835, + "book_reference": 9, + "created_at": "2025-12-16T16:15:03.613", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2311, + "fields": { + "hadis": 1836, + "book_reference": 10, + "created_at": "2025-12-16T16:15:04.102", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2312, + "fields": { + "hadis": 1837, + "book_reference": 9, + "created_at": "2025-12-16T16:15:04.586", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2313, + "fields": { + "hadis": 1837, + "book_reference": 8, + "created_at": "2025-12-16T16:15:04.973", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2314, + "fields": { + "hadis": 1838, + "book_reference": 8, + "created_at": "2025-12-16T16:15:05.459", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2315, + "fields": { + "hadis": 1838, + "book_reference": 7, + "created_at": "2025-12-16T16:15:05.849", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2316, + "fields": { + "hadis": 1838, + "book_reference": 4, + "created_at": "2025-12-16T16:15:06.233", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2317, + "fields": { + "hadis": 1839, + "book_reference": 7, + "created_at": "2025-12-16T16:15:06.718", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2318, + "fields": { + "hadis": 1840, + "book_reference": 4, + "created_at": "2025-12-16T16:15:07.202", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2319, + "fields": { + "hadis": 1840, + "book_reference": 2, + "created_at": "2025-12-16T16:15:07.590", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2320, + "fields": { + "hadis": 1841, + "book_reference": 2, + "created_at": "2025-12-16T16:15:08.071", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2321, + "fields": { + "hadis": 1842, + "book_reference": 11, + "created_at": "2025-12-16T16:15:08.554", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2322, + "fields": { + "hadis": 1843, + "book_reference": 10, + "created_at": "2025-12-16T16:15:09.039", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2323, + "fields": { + "hadis": 1843, + "book_reference": 9, + "created_at": "2025-12-16T16:15:09.425", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2324, + "fields": { + "hadis": 1844, + "book_reference": 9, + "created_at": "2025-12-16T16:15:09.907", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2325, + "fields": { + "hadis": 1844, + "book_reference": 8, + "created_at": "2025-12-16T16:15:10.295", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2326, + "fields": { + "hadis": 1844, + "book_reference": 7, + "created_at": "2025-12-16T16:15:10.681", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2327, + "fields": { + "hadis": 1845, + "book_reference": 8, + "created_at": "2025-12-16T16:15:11.171", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2328, + "fields": { + "hadis": 1846, + "book_reference": 7, + "created_at": "2025-12-16T16:15:11.657", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2329, + "fields": { + "hadis": 1846, + "book_reference": 4, + "created_at": "2025-12-16T16:15:12.042", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2330, + "fields": { + "hadis": 1847, + "book_reference": 4, + "created_at": "2025-12-16T16:15:12.524", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2331, + "fields": { + "hadis": 1847, + "book_reference": 2, + "created_at": "2025-12-16T16:15:12.908", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2332, + "fields": { + "hadis": 1848, + "book_reference": 2, + "created_at": "2025-12-16T16:15:13.392", + "description": "This is one of the most frequently cited ahadith on this topic." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2333, + "fields": { + "hadis": 1849, + "book_reference": 11, + "created_at": "2025-12-16T16:15:13.876", + "description": "This hadith is recorded in the collection with full chain of narration." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2334, + "fields": { + "hadis": 1849, + "book_reference": 10, + "created_at": "2025-12-16T16:15:14.264", + "description": "The narration is found in multiple reliable sources with slight variations." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2335, + "fields": { + "hadis": 1850, + "book_reference": 10, + "created_at": "2025-12-16T16:15:14.749", + "description": "This is a widely transmitted hadith with consistent narration across sources." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2336, + "fields": { + "hadis": 1850, + "book_reference": 9, + "created_at": "2025-12-16T16:15:15.133", + "description": "The hadith is reported with authentic chain and acceptable wording." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2337, + "fields": { + "hadis": 1850, + "book_reference": 8, + "created_at": "2025-12-16T16:15:15.522", + "description": "This tradition appears in the primary sources with confirmed authenticity." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2338, + "fields": { + "hadis": 1851, + "book_reference": 9, + "created_at": "2025-12-16T16:15:16.003", + "description": "The narration demonstrates the scholarly consensus on this issue." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2339, + "fields": { + "hadis": 1852, + "book_reference": 8, + "created_at": "2025-12-16T16:15:16.487", + "description": "The hadith is part of the foundational traditions in this category." + } +}, +{ + "model": "hadis.hadisreference", + "pk": 2340, + "fields": { + "hadis": 1852, + "book_reference": 7, + "created_at": "2025-12-16T16:15:16.875", + "description": "This is one of the most frequently cited ahadith on this topic." + } +} +] diff --git a/apps/hadis/fixtures/hadises1_reformatted.json b/apps/hadis/fixtures/hadises1_reformatted.json new file mode 100644 index 0000000..020045b --- /dev/null +++ b/apps/hadis/fixtures/hadises1_reformatted.json @@ -0,0 +1,2415 @@ +[ + { + "model": "hadis.hadiscollection", + "pk": 1, + "fields": { + "title": [ + { + "text": "Purification & Prayer", + "language_code": "en" + } + ], + "slug": "purification-prayer", + "summary": [ + { + "text": "Collection of ahadith related to purification (Wudu, Ghusl) and prayer rituals.", + "language_code": "en" + } + ], + "status": true, + "order": 1, + "thumbnail": null, + "created_at": "2025-12-16T16:14:31.011", + "updated_at": "2025-12-16T16:14:31.011" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 2, + "fields": { + "title": [ + { + "text": "Zakat & Charity", + "language_code": "en" + } + ], + "slug": "zakat-charity", + "summary": [ + { + "text": "Ahadith discussing the obligation of zakat and charitable giving in Islam.", + "language_code": "en" + } + ], + "status": true, + "order": 2, + "thumbnail": null, + "created_at": "2025-12-16T16:14:31.496", + "updated_at": "2025-12-16T16:14:31.496" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 3, + "fields": { + "title": [ + { + "text": "Fasting & Ramadan", + "language_code": "en" + } + ], + "slug": "fasting-ramadan", + "summary": [ + { + "text": "Collection of authentic ahadith about fasting, its virtues, and Ramadan practices.", + "language_code": "en" + } + ], + "status": true, + "order": 3, + "thumbnail": null, + "created_at": "2025-12-16T16:14:31.984", + "updated_at": "2025-12-16T16:14:31.984" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 4, + "fields": { + "title": [ + { + "text": "Hajj & Umrah", + "language_code": "en" + } + ], + "slug": "hajj-umrah", + "summary": [ + { + "text": "Ahadith related to the pilgrimage to Mecca and the spiritual practices involved.", + "language_code": "en" + } + ], + "status": true, + "order": 4, + "thumbnail": null, + "created_at": "2025-12-16T16:14:32.471", + "updated_at": "2025-12-16T16:14:32.471" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 5, + "fields": { + "title": [ + { + "text": "Ethics & Morality", + "language_code": "en" + } + ], + "slug": "ethics-morality", + "summary": [ + { + "text": "Ahadith emphasizing Islamic ethics, good character, and moral conduct.", + "language_code": "en" + } + ], + "status": true, + "order": 5, + "thumbnail": null, + "created_at": "2025-12-16T16:14:32.956", + "updated_at": "2025-12-16T16:14:32.956" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 6, + "fields": { + "title": [ + { + "text": "Knowledge & Learning", + "language_code": "en" + } + ], + "slug": "knowledge-learning", + "summary": [ + { + "text": "Collection emphasizing the importance of seeking knowledge in Islam.", + "language_code": "en" + } + ], + "status": true, + "order": 6, + "thumbnail": null, + "created_at": "2025-12-16T16:14:33.438", + "updated_at": "2025-12-16T16:14:33.438" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 7, + "fields": { + "title": [ + { + "text": "Family & Relations", + "language_code": "en" + } + ], + "slug": "family-relations", + "summary": [ + { + "text": "Ahadith about family relationships, rights of parents, spouses, and children.", + "language_code": "en" + } + ], + "status": true, + "order": 7, + "thumbnail": null, + "created_at": "2025-12-16T16:14:33.925", + "updated_at": "2025-12-16T16:14:33.925" + } + }, + { + "model": "hadis.hadiscollection", + "pk": 8, + "fields": { + "title": [ + { + "text": "Business & Trade", + "language_code": "en" + } + ], + "slug": "business-trade", + "summary": [ + { + "text": "Ahadith related to ethical business practices and commerce in Islam.", + "language_code": "en" + } + ], + "status": true, + "order": 8, + "thumbnail": null, + "created_at": "2025-12-16T16:14:34.407", + "updated_at": "2025-12-16T16:14:34.407" + } + }, + + { + "model": "hadis.hadisreference", + "pk": 2193, + "fields": { + "hadis": 1800, + "book_reference": null, + "created_at": "2025-07-04T18:33:06.176", + "description": [ + { + "text": "Volume 4, Page 813, Hadis #829", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2194, + "fields": { + "hadis": 1801, + "book_reference": null, + "created_at": "2025-07-04T18:33:08.217", + "description": [ + { + "text": "Volume 4, Page 453, Hadis #6769", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2195, + "fields": { + "hadis": 1802, + "book_reference": null, + "created_at": "2025-07-04T18:33:10.247", + "description": [ + { + "text": "Volume 2, Page 221, Hadis #2454", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2196, + "fields": { + "hadis": 1803, + "book_reference": null, + "created_at": "2025-07-04T18:33:12.158", + "description": [ + { + "text": "Volume 2, Page 457, Hadis #8423", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2197, + "fields": { + "hadis": 1804, + "book_reference": null, + "created_at": "2025-07-04T18:33:14.349", + "description": [ + { + "text": "Volume 1, Page 318, Hadis #7345", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2198, + "fields": { + "hadis": 1805, + "book_reference": null, + "created_at": "2025-07-04T18:33:16.555", + "description": [ + { + "text": "Volume 2, Page 971, Hadis #837", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2199, + "fields": { + "hadis": 1806, + "book_reference": null, + "created_at": "2025-07-04T18:33:18.660", + "description": [ + { + "text": "Volume 5, Page 331, Hadis #5908", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2200, + "fields": { + "hadis": 1807, + "book_reference": null, + "created_at": "2025-07-04T18:33:20.684", + "description": [ + { + "text": "Volume 5, Page 45, Hadis #5691", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2201, + "fields": { + "hadis": 1808, + "book_reference": null, + "created_at": "2025-07-04T18:33:22.687", + "description": [ + { + "text": "Volume 4, Page 520, Hadis #8315", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2202, + "fields": { + "hadis": 1809, + "book_reference": null, + "created_at": "2025-07-04T18:33:24.657", + "description": [ + { + "text": "Volume 1, Page 859, Hadis #4915", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2203, + "fields": { + "hadis": 1810, + "book_reference": null, + "created_at": "2025-07-04T18:33:26.871", + "description": [ + { + "text": "Volume 1, Page 67, Hadis #8829", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2204, + "fields": { + "hadis": 1811, + "book_reference": null, + "created_at": "2025-07-04T18:33:29.044", + "description": [ + { + "text": "Volume 2, Page 146, Hadis #8238", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2205, + "fields": { + "hadis": 1812, + "book_reference": null, + "created_at": "2025-07-04T18:33:31.268", + "description": [ + { + "text": "Volume 5, Page 638, Hadis #6959", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2206, + "fields": { + "hadis": 1813, + "book_reference": null, + "created_at": "2025-07-04T18:33:33.240", + "description": [ + { + "text": "Volume 2, Page 459, Hadis #2809", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2207, + "fields": { + "hadis": 1814, + "book_reference": null, + "created_at": "2025-07-04T18:33:35.361", + "description": [ + { + "text": "Volume 5, Page 487, Hadis #9558", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2208, + "fields": { + "hadis": 1815, + "book_reference": null, + "created_at": "2025-07-04T18:33:37.960", + "description": [ + { + "text": "Volume 4, Page 58, Hadis #8090", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2209, + "fields": { + "hadis": 1816, + "book_reference": null, + "created_at": "2025-07-04T18:33:40.074", + "description": [ + { + "text": "Volume 4, Page 172, Hadis #198", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2210, + "fields": { + "hadis": 1817, + "book_reference": null, + "created_at": "2025-07-04T18:33:42.060", + "description": [ + { + "text": "Volume 5, Page 414, Hadis #9060", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2211, + "fields": { + "hadis": 1818, + "book_reference": null, + "created_at": "2025-07-04T18:33:44.508", + "description": [ + { + "text": "Volume 1, Page 376, Hadis #9644", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2212, + "fields": { + "hadis": 1819, + "book_reference": null, + "created_at": "2025-07-04T18:33:46.599", + "description": [ + { + "text": "Volume 2, Page 952, Hadis #520", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2213, + "fields": { + "hadis": 1820, + "book_reference": null, + "created_at": "2025-07-04T18:33:48.637", + "description": [ + { + "text": "Volume 5, Page 905, Hadis #51", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2214, + "fields": { + "hadis": 1821, + "book_reference": null, + "created_at": "2025-07-04T18:33:50.508", + "description": [ + { + "text": "Volume 2, Page 759, Hadis #2569", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2215, + "fields": { + "hadis": 1822, + "book_reference": null, + "created_at": "2025-07-04T18:33:52.494", + "description": [ + { + "text": "Volume 1, Page 712, Hadis #3339", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2216, + "fields": { + "hadis": 1823, + "book_reference": null, + "created_at": "2025-07-04T18:33:54.751", + "description": [ + { + "text": "Volume 4, Page 380, Hadis #7766", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2217, + "fields": { + "hadis": 1824, + "book_reference": null, + "created_at": "2025-07-04T18:33:57.092", + "description": [ + { + "text": "Volume 3, Page 744, Hadis #8171", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2218, + "fields": { + "hadis": 1825, + "book_reference": null, + "created_at": "2025-07-04T18:34:00.118", + "description": [ + { + "text": "Volume 5, Page 557, Hadis #9068", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2219, + "fields": { + "hadis": 1826, + "book_reference": null, + "created_at": "2025-07-04T18:34:02.226", + "description": [ + { + "text": "Volume 2, Page 375, Hadis #5010", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2220, + "fields": { + "hadis": 1827, + "book_reference": null, + "created_at": "2025-07-04T18:34:06.084", + "description": [ + { + "text": "Volume 2, Page 150, Hadis #8150", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2221, + "fields": { + "hadis": 1828, + "book_reference": null, + "created_at": "2025-07-04T18:34:08.819", + "description": [ + { + "text": "Volume 5, Page 184, Hadis #9415", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2222, + "fields": { + "hadis": 1829, + "book_reference": null, + "created_at": "2025-07-04T18:34:10.833", + "description": [ + { + "text": "Volume 4, Page 64, Hadis #1556", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2223, + "fields": { + "hadis": 1830, + "book_reference": null, + "created_at": "2025-07-04T18:34:12.834", + "description": [ + { + "text": "Volume 2, Page 392, Hadis #3772", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2224, + "fields": { + "hadis": 1831, + "book_reference": null, + "created_at": "2025-07-04T18:34:15.679", + "description": [ + { + "text": "Volume 4, Page 154, Hadis #642", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2225, + "fields": { + "hadis": 1832, + "book_reference": null, + "created_at": "2025-07-04T18:34:18.163", + "description": [ + { + "text": "Volume 3, Page 403, Hadis #5230", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2226, + "fields": { + "hadis": 1833, + "book_reference": null, + "created_at": "2025-07-04T18:34:22.029", + "description": [ + { + "text": "Volume 2, Page 825, Hadis #5258", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2227, + "fields": { + "hadis": 1834, + "book_reference": null, + "created_at": "2025-07-04T18:34:25.991", + "description": [ + { + "text": "Volume 1, Page 62, Hadis #1978", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2228, + "fields": { + "hadis": 1835, + "book_reference": null, + "created_at": "2025-07-04T18:34:28.057", + "description": [ + { + "text": "Volume 5, Page 595, Hadis #5277", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2229, + "fields": { + "hadis": 1836, + "book_reference": null, + "created_at": "2025-07-04T18:34:30.065", + "description": [ + { + "text": "Volume 2, Page 102, Hadis #7664", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2230, + "fields": { + "hadis": 1837, + "book_reference": null, + "created_at": "2025-07-04T18:34:32.218", + "description": [ + { + "text": "Volume 1, Page 788, Hadis #2558", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2231, + "fields": { + "hadis": 1838, + "book_reference": null, + "created_at": "2025-07-04T18:34:34.547", + "description": [ + { + "text": "Volume 2, Page 177, Hadis #3176", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2232, + "fields": { + "hadis": 1839, + "book_reference": null, + "created_at": "2025-07-04T18:34:36.889", + "description": [ + { + "text": "Volume 5, Page 377, Hadis #2332", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2233, + "fields": { + "hadis": 1840, + "book_reference": null, + "created_at": "2025-07-04T18:34:41.010", + "description": [ + { + "text": "Volume 4, Page 523, Hadis #7037", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2234, + "fields": { + "hadis": 1841, + "book_reference": null, + "created_at": "2025-07-04T18:34:50.251", + "description": [ + { + "text": "Volume 3, Page 137, Hadis #4052", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2235, + "fields": { + "hadis": 1842, + "book_reference": null, + "created_at": "2025-07-04T18:34:52.824", + "description": [ + { + "text": "Volume 1, Page 766, Hadis #917", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2236, + "fields": { + "hadis": 1843, + "book_reference": null, + "created_at": "2025-07-04T18:34:55.189", + "description": [ + { + "text": "Volume 5, Page 213, Hadis #8199", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2237, + "fields": { + "hadis": 1844, + "book_reference": null, + "created_at": "2025-07-04T18:34:57.099", + "description": [ + { + "text": "Volume 2, Page 337, Hadis #7569", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2238, + "fields": { + "hadis": 1845, + "book_reference": null, + "created_at": "2025-07-04T18:34:58.996", + "description": [ + { + "text": "Volume 2, Page 29, Hadis #2108", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2239, + "fields": { + "hadis": 1846, + "book_reference": null, + "created_at": "2025-07-04T18:35:00.923", + "description": [ + { + "text": "Volume 1, Page 35, Hadis #6469", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2240, + "fields": { + "hadis": 1847, + "book_reference": null, + "created_at": "2025-07-04T18:35:02.820", + "description": [ + { + "text": "Volume 1, Page 300, Hadis #8301", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2241, + "fields": { + "hadis": 1848, + "book_reference": null, + "created_at": "2025-07-04T18:35:04.835", + "description": [ + { + "text": "Volume 5, Page 790, Hadis #9347", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2242, + "fields": { + "hadis": 1849, + "book_reference": null, + "created_at": "2025-07-04T18:35:06.665", + "description": [ + { + "text": "Volume 1, Page 6, Hadis #544", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2243, + "fields": { + "hadis": 1850, + "book_reference": null, + "created_at": "2025-07-04T18:35:08.599", + "description": [ + { + "text": "Volume 2, Page 834, Hadis #5069", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2244, + "fields": { + "hadis": 1851, + "book_reference": null, + "created_at": "2025-07-04T18:35:10.547", + "description": [ + { + "text": "Volume 4, Page 738, Hadis #351", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2245, + "fields": { + "hadis": 1800, + "book_reference": 11, + "created_at": "2025-12-16T16:14:34.999", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2246, + "fields": { + "hadis": 1801, + "book_reference": 10, + "created_at": "2025-12-16T16:14:35.489", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2247, + "fields": { + "hadis": 1801, + "book_reference": 9, + "created_at": "2025-12-16T16:14:35.876", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2248, + "fields": { + "hadis": 1802, + "book_reference": 9, + "created_at": "2025-12-16T16:14:36.360", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2249, + "fields": { + "hadis": 1802, + "book_reference": 8, + "created_at": "2025-12-16T16:14:36.747", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2250, + "fields": { + "hadis": 1802, + "book_reference": 7, + "created_at": "2025-12-16T16:14:37.134", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2251, + "fields": { + "hadis": 1803, + "book_reference": 8, + "created_at": "2025-12-16T16:14:37.621", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2252, + "fields": { + "hadis": 1804, + "book_reference": 7, + "created_at": "2025-12-16T16:14:38.110", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2253, + "fields": { + "hadis": 1804, + "book_reference": 4, + "created_at": "2025-12-16T16:14:38.499", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2254, + "fields": { + "hadis": 1805, + "book_reference": 4, + "created_at": "2025-12-16T16:14:38.990", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2255, + "fields": { + "hadis": 1805, + "book_reference": 2, + "created_at": "2025-12-16T16:14:39.377", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2256, + "fields": { + "hadis": 1806, + "book_reference": 2, + "created_at": "2025-12-16T16:14:39.864", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2257, + "fields": { + "hadis": 1807, + "book_reference": 11, + "created_at": "2025-12-16T16:14:40.349", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2258, + "fields": { + "hadis": 1807, + "book_reference": 10, + "created_at": "2025-12-16T16:14:40.740", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2259, + "fields": { + "hadis": 1808, + "book_reference": 10, + "created_at": "2025-12-16T16:14:41.229", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2260, + "fields": { + "hadis": 1808, + "book_reference": 9, + "created_at": "2025-12-16T16:14:41.616", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2261, + "fields": { + "hadis": 1808, + "book_reference": 8, + "created_at": "2025-12-16T16:14:42.004", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2262, + "fields": { + "hadis": 1809, + "book_reference": 9, + "created_at": "2025-12-16T16:14:42.490", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2263, + "fields": { + "hadis": 1810, + "book_reference": 8, + "created_at": "2025-12-16T16:14:42.978", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2264, + "fields": { + "hadis": 1810, + "book_reference": 7, + "created_at": "2025-12-16T16:14:43.364", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2265, + "fields": { + "hadis": 1811, + "book_reference": 7, + "created_at": "2025-12-16T16:14:43.849", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2266, + "fields": { + "hadis": 1811, + "book_reference": 4, + "created_at": "2025-12-16T16:14:44.238", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2267, + "fields": { + "hadis": 1811, + "book_reference": 2, + "created_at": "2025-12-16T16:14:44.626", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2268, + "fields": { + "hadis": 1812, + "book_reference": 4, + "created_at": "2025-12-16T16:14:45.112", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2269, + "fields": { + "hadis": 1813, + "book_reference": 2, + "created_at": "2025-12-16T16:14:45.593", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2270, + "fields": { + "hadis": 1814, + "book_reference": 11, + "created_at": "2025-12-16T16:14:46.076", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2271, + "fields": { + "hadis": 1814, + "book_reference": 10, + "created_at": "2025-12-16T16:14:46.461", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2272, + "fields": { + "hadis": 1814, + "book_reference": 9, + "created_at": "2025-12-16T16:14:46.845", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2273, + "fields": { + "hadis": 1815, + "book_reference": 10, + "created_at": "2025-12-16T16:14:47.329", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2274, + "fields": { + "hadis": 1816, + "book_reference": 9, + "created_at": "2025-12-16T16:14:47.816", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2275, + "fields": { + "hadis": 1816, + "book_reference": 8, + "created_at": "2025-12-16T16:14:48.201", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2276, + "fields": { + "hadis": 1817, + "book_reference": 8, + "created_at": "2025-12-16T16:14:48.684", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2277, + "fields": { + "hadis": 1817, + "book_reference": 7, + "created_at": "2025-12-16T16:14:49.073", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2278, + "fields": { + "hadis": 1817, + "book_reference": 4, + "created_at": "2025-12-16T16:14:49.459", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2279, + "fields": { + "hadis": 1818, + "book_reference": 7, + "created_at": "2025-12-16T16:14:49.946", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2280, + "fields": { + "hadis": 1819, + "book_reference": 4, + "created_at": "2025-12-16T16:14:50.429", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2281, + "fields": { + "hadis": 1819, + "book_reference": 2, + "created_at": "2025-12-16T16:14:50.813", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2282, + "fields": { + "hadis": 1820, + "book_reference": 2, + "created_at": "2025-12-16T16:14:51.297", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2283, + "fields": { + "hadis": 1821, + "book_reference": 11, + "created_at": "2025-12-16T16:14:51.783", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2284, + "fields": { + "hadis": 1822, + "book_reference": 10, + "created_at": "2025-12-16T16:14:52.267", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2285, + "fields": { + "hadis": 1822, + "book_reference": 9, + "created_at": "2025-12-16T16:14:52.654", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2286, + "fields": { + "hadis": 1823, + "book_reference": 9, + "created_at": "2025-12-16T16:14:53.138", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2287, + "fields": { + "hadis": 1823, + "book_reference": 8, + "created_at": "2025-12-16T16:14:53.523", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2288, + "fields": { + "hadis": 1823, + "book_reference": 7, + "created_at": "2025-12-16T16:14:53.907", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2289, + "fields": { + "hadis": 1824, + "book_reference": 8, + "created_at": "2025-12-16T16:14:54.393", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2290, + "fields": { + "hadis": 1825, + "book_reference": 7, + "created_at": "2025-12-16T16:14:54.879", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2291, + "fields": { + "hadis": 1825, + "book_reference": 4, + "created_at": "2025-12-16T16:14:55.264", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2292, + "fields": { + "hadis": 1826, + "book_reference": 4, + "created_at": "2025-12-16T16:14:55.748", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2293, + "fields": { + "hadis": 1826, + "book_reference": 2, + "created_at": "2025-12-16T16:14:56.133", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2294, + "fields": { + "hadis": 1827, + "book_reference": 2, + "created_at": "2025-12-16T16:14:56.620", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2295, + "fields": { + "hadis": 1828, + "book_reference": 11, + "created_at": "2025-12-16T16:14:57.105", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2296, + "fields": { + "hadis": 1828, + "book_reference": 10, + "created_at": "2025-12-16T16:14:57.490", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2297, + "fields": { + "hadis": 1829, + "book_reference": 10, + "created_at": "2025-12-16T16:14:57.976", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2298, + "fields": { + "hadis": 1829, + "book_reference": 9, + "created_at": "2025-12-16T16:14:58.361", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2299, + "fields": { + "hadis": 1829, + "book_reference": 8, + "created_at": "2025-12-16T16:14:58.751", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2300, + "fields": { + "hadis": 1830, + "book_reference": 9, + "created_at": "2025-12-16T16:14:59.236", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2301, + "fields": { + "hadis": 1831, + "book_reference": 8, + "created_at": "2025-12-16T16:14:59.720", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2302, + "fields": { + "hadis": 1831, + "book_reference": 7, + "created_at": "2025-12-16T16:15:00.106", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2303, + "fields": { + "hadis": 1832, + "book_reference": 7, + "created_at": "2025-12-16T16:15:00.592", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2304, + "fields": { + "hadis": 1832, + "book_reference": 4, + "created_at": "2025-12-16T16:15:00.979", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2305, + "fields": { + "hadis": 1832, + "book_reference": 2, + "created_at": "2025-12-16T16:15:01.365", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2306, + "fields": { + "hadis": 1833, + "book_reference": 4, + "created_at": "2025-12-16T16:15:01.855", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2307, + "fields": { + "hadis": 1834, + "book_reference": 2, + "created_at": "2025-12-16T16:15:02.347", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2308, + "fields": { + "hadis": 1835, + "book_reference": 11, + "created_at": "2025-12-16T16:15:02.834", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2309, + "fields": { + "hadis": 1835, + "book_reference": 10, + "created_at": "2025-12-16T16:15:03.224", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2310, + "fields": { + "hadis": 1835, + "book_reference": 9, + "created_at": "2025-12-16T16:15:03.613", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2311, + "fields": { + "hadis": 1836, + "book_reference": 10, + "created_at": "2025-12-16T16:15:04.102", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2312, + "fields": { + "hadis": 1837, + "book_reference": 9, + "created_at": "2025-12-16T16:15:04.586", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2313, + "fields": { + "hadis": 1837, + "book_reference": 8, + "created_at": "2025-12-16T16:15:04.973", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2314, + "fields": { + "hadis": 1838, + "book_reference": 8, + "created_at": "2025-12-16T16:15:05.459", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2315, + "fields": { + "hadis": 1838, + "book_reference": 7, + "created_at": "2025-12-16T16:15:05.849", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2316, + "fields": { + "hadis": 1838, + "book_reference": 4, + "created_at": "2025-12-16T16:15:06.233", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2317, + "fields": { + "hadis": 1839, + "book_reference": 7, + "created_at": "2025-12-16T16:15:06.718", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2318, + "fields": { + "hadis": 1840, + "book_reference": 4, + "created_at": "2025-12-16T16:15:07.202", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2319, + "fields": { + "hadis": 1840, + "book_reference": 2, + "created_at": "2025-12-16T16:15:07.590", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2320, + "fields": { + "hadis": 1841, + "book_reference": 2, + "created_at": "2025-12-16T16:15:08.071", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2321, + "fields": { + "hadis": 1842, + "book_reference": 11, + "created_at": "2025-12-16T16:15:08.554", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2322, + "fields": { + "hadis": 1843, + "book_reference": 10, + "created_at": "2025-12-16T16:15:09.039", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2323, + "fields": { + "hadis": 1843, + "book_reference": 9, + "created_at": "2025-12-16T16:15:09.425", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2324, + "fields": { + "hadis": 1844, + "book_reference": 9, + "created_at": "2025-12-16T16:15:09.907", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2325, + "fields": { + "hadis": 1844, + "book_reference": 8, + "created_at": "2025-12-16T16:15:10.295", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2326, + "fields": { + "hadis": 1844, + "book_reference": 7, + "created_at": "2025-12-16T16:15:10.681", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2327, + "fields": { + "hadis": 1845, + "book_reference": 8, + "created_at": "2025-12-16T16:15:11.171", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2328, + "fields": { + "hadis": 1846, + "book_reference": 7, + "created_at": "2025-12-16T16:15:11.657", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2329, + "fields": { + "hadis": 1846, + "book_reference": 4, + "created_at": "2025-12-16T16:15:12.042", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2330, + "fields": { + "hadis": 1847, + "book_reference": 4, + "created_at": "2025-12-16T16:15:12.524", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2331, + "fields": { + "hadis": 1847, + "book_reference": 2, + "created_at": "2025-12-16T16:15:12.908", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2332, + "fields": { + "hadis": 1848, + "book_reference": 2, + "created_at": "2025-12-16T16:15:13.392", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2333, + "fields": { + "hadis": 1849, + "book_reference": 11, + "created_at": "2025-12-16T16:15:13.876", + "description": [ + { + "text": "This hadith is recorded in the collection with full chain of narration.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2334, + "fields": { + "hadis": 1849, + "book_reference": 10, + "created_at": "2025-12-16T16:15:14.264", + "description": [ + { + "text": "The narration is found in multiple reliable sources with slight variations.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2335, + "fields": { + "hadis": 1850, + "book_reference": 10, + "created_at": "2025-12-16T16:15:14.749", + "description": [ + { + "text": "This is a widely transmitted hadith with consistent narration across sources.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2336, + "fields": { + "hadis": 1850, + "book_reference": 9, + "created_at": "2025-12-16T16:15:15.133", + "description": [ + { + "text": "The hadith is reported with authentic chain and acceptable wording.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2337, + "fields": { + "hadis": 1850, + "book_reference": 8, + "created_at": "2025-12-16T16:15:15.522", + "description": [ + { + "text": "This tradition appears in the primary sources with confirmed authenticity.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2338, + "fields": { + "hadis": 1851, + "book_reference": 9, + "created_at": "2025-12-16T16:15:16.003", + "description": [ + { + "text": "The narration demonstrates the scholarly consensus on this issue.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2339, + "fields": { + "hadis": 1852, + "book_reference": 8, + "created_at": "2025-12-16T16:15:16.487", + "description": [ + { + "text": "The hadith is part of the foundational traditions in this category.", + "language_code": "en" + } + ] + } + }, + { + "model": "hadis.hadisreference", + "pk": 2340, + "fields": { + "hadis": 1852, + "book_reference": 7, + "created_at": "2025-12-16T16:15:16.875", + "description": [ + { + "text": "This is one of the most frequently cited ahadith on this topic.", + "language_code": "en" + } + ] + } + } +] \ No newline at end of file diff --git a/apps/hadis/fixtures/hadises2_backup.json b/apps/hadis/fixtures/hadises2_backup.json new file mode 100644 index 0000000..09d1480 --- /dev/null +++ b/apps/hadis/fixtures/hadises2_backup.json @@ -0,0 +1,2605 @@ +[ +{ + "model": "hadis.hadis", + "pk": 1800, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (1)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:04.441", + "updated_at": "2025-07-04T18:33:04.441", + "tags": [ + 510, + 514, + 520 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1801, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (2)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:06.754", + "updated_at": "2025-07-04T18:33:06.754", + "tags": [ + 513, + 517, + 535 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1802, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (3)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:08.796", + "updated_at": "2025-07-04T18:33:08.796", + "tags": [ + 521, + 526, + 538 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1803, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (4)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:10.776", + "updated_at": "2025-07-04T18:33:10.776", + "tags": [ + 509, + 522, + 534 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1804, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (5)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:13.008", + "updated_at": "2025-07-04T18:33:13.008", + "tags": [ + 509, + 510, + 516 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1805, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (6)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:14.978", + "updated_at": "2025-07-04T18:33:14.978", + "tags": [ + 509, + 512, + 513 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1806, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (7)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:17.144", + "updated_at": "2025-07-04T18:33:17.144", + "tags": [ + 508, + 528, + 529 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1807, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (8)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:19.253", + "updated_at": "2025-07-04T18:33:19.253", + "tags": [ + 509, + 511, + 536 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1808, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (9)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:21.262", + "updated_at": "2025-07-04T18:33:21.262", + "tags": [ + 516, + 528, + 535 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1809, + "fields": { + "category": 330, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (10)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:23.233", + "updated_at": "2025-07-04T18:33:23.233", + "tags": [ + 515, + 516, + 528 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1810, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (1)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:25.240", + "updated_at": "2025-07-04T18:33:25.240", + "tags": [ + 507, + 531, + 533 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1811, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (2)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:27.438", + "updated_at": "2025-07-04T18:33:27.438", + "tags": [ + 518, + 521, + 525 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1812, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (3)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:29.577", + "updated_at": "2025-07-04T18:33:29.577", + "tags": [ + 509, + 512, + 521 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1813, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (4)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:31.791", + "updated_at": "2025-07-04T18:33:31.791", + "tags": [ + 514, + 520, + 522 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1814, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (5)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:33.795", + "updated_at": "2025-07-04T18:33:33.795", + "tags": [ + 516, + 522, + 538 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1815, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (6)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:36.241", + "updated_at": "2025-07-04T18:33:36.241", + "tags": [ + 511, + 524, + 525 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1816, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (7)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:38.512", + "updated_at": "2025-07-04T18:33:38.512", + "tags": [ + 507, + 531, + 535 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1817, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (8)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:40.664", + "updated_at": "2025-07-04T18:33:40.664", + "tags": [ + 507, + 521, + 530 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1818, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (9)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:42.835", + "updated_at": "2025-07-04T18:33:42.835", + "tags": [ + 516, + 518, + 530 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1819, + "fields": { + "category": 331, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (10)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:45.129", + "updated_at": "2025-07-04T18:33:45.129", + "tags": [ + 511, + 525, + 534 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1820, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (1)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:47.179", + "updated_at": "2025-07-04T18:33:47.179", + "tags": [ + 514, + 516, + 533 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1821, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (2)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:49.175", + "updated_at": "2025-07-04T18:33:49.175", + "tags": [ + 507, + 509, + 515 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1822, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (3)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:51.029", + "updated_at": "2025-07-04T18:33:51.029", + "tags": [ + 514, + 528, + 536 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1823, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (4)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:53.121", + "updated_at": "2025-07-04T18:33:53.121", + "tags": [ + 517, + 527, + 531 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1824, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (5)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:55.417", + "updated_at": "2025-07-04T18:33:55.417", + "tags": [ + 507, + 526, + 532 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1825, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (6)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:33:57.960", + "updated_at": "2025-07-04T18:33:57.960", + "tags": [ + 511, + 516, + 517 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1826, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (7)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:00.637", + "updated_at": "2025-07-04T18:34:00.637", + "tags": [ + 512, + 515, + 520 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1827, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (8)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:02.802", + "updated_at": "2025-07-04T18:34:02.802", + "tags": [ + 510, + 527, + 536 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1828, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (9)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:07.124", + "updated_at": "2025-07-04T18:34:07.124", + "tags": [ + 508, + 509, + 517 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1829, + "fields": { + "category": 332, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (10)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:09.405", + "updated_at": "2025-07-04T18:34:09.405", + "tags": [ + 514, + 528, + 534 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1830, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (1)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:11.385", + "updated_at": "2025-07-04T18:34:11.385", + "tags": [ + 517, + 524, + 536 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1831, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (2)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:13.520", + "updated_at": "2025-07-04T18:34:13.520", + "tags": [ + 507, + 533, + 537 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1832, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (3)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:16.292", + "updated_at": "2025-07-04T18:34:16.292", + "tags": [ + 514, + 525, + 538 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1833, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (4)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:18.934", + "updated_at": "2025-07-04T18:34:18.934", + "tags": [ + 522, + 529, + 531 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1834, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (5)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:24.322", + "updated_at": "2025-07-04T18:34:24.322", + "tags": [ + 512, + 520, + 535 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1835, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (6)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:26.539", + "updated_at": "2025-07-04T18:34:26.539", + "tags": [ + 512, + 532, + 533 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1836, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (7)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:28.610", + "updated_at": "2025-07-04T18:34:28.610", + "tags": [ + 517, + 519, + 526 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1837, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (8)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:30.634", + "updated_at": "2025-07-04T18:34:30.634", + "tags": [ + 524, + 527, + 535 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1838, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (9)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:32.831", + "updated_at": "2025-07-04T18:34:32.831", + "tags": [ + 515, + 519, + 538 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1839, + "fields": { + "category": 333, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о молитве (10)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:34:35.239", + "updated_at": "2025-07-04T18:34:35.239", + "tags": [ + 513, + 527, + 528 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1840, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (1)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:37.573", + "updated_at": "2025-07-04T18:34:37.573", + "tags": [ + 509, + 511, + 531 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1841, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (2)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:43.509", + "updated_at": "2025-07-04T18:34:43.509", + "tags": [ + 516, + 525, + 527 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1842, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (3)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:51.461", + "updated_at": "2025-07-04T18:34:51.461", + "tags": [ + 510, + 516, + 534 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1843, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (4)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:53.805", + "updated_at": "2025-07-04T18:34:53.805", + "tags": [ + 512, + 514, + 522 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1844, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (5)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:55.725", + "updated_at": "2025-07-04T18:34:55.725", + "tags": [ + 525, + 530, + 532 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1845, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (6)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:57.641", + "updated_at": "2025-07-04T18:34:57.641", + "tags": [ + 520, + 525, + 529 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1846, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (7)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:34:59.557", + "updated_at": "2025-07-04T18:34:59.557", + "tags": [ + 522, + 527, + 536 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1847, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (8)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:35:01.450", + "updated_at": "2025-07-04T18:35:01.450", + "tags": [ + 508, + 526, + 528 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1848, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (9)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:35:03.443", + "updated_at": "2025-07-04T18:35:03.443", + "tags": [ + 509, + 511, + 515 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1849, + "fields": { + "category": 334, + "number": 1, + "title_narrator": null, + "title": "Достоинство поста и его духовные плоды - Аяты о посте (10)", + "description": null, + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "created_at": "2025-07-04T18:35:05.357", + "updated_at": "2025-07-04T18:35:05.357", + "tags": [ + 517, + 520, + 528 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1850, + "fields": { + "category": 335, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о закяте (1)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:35:07.197", + "updated_at": "2025-07-04T18:35:07.197", + "tags": [ + 508, + 521, + 529 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1851, + "fields": { + "category": 335, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о закяте (2)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:35:09.126", + "updated_at": "2025-07-04T18:35:09.126", + "tags": [ + 507, + 513, + 534 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1852, + "fields": { + "category": 335, + "number": 1, + "title_narrator": null, + "title": "Достоинство молитвы и ее место в религии - Аяты о закяте (3)", + "description": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": null, + "address": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "created_at": "2025-07-04T18:35:11.121", + "updated_at": "2025-07-04T18:35:11.121", + "tags": [ + 524, + 530, + 532 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1877, + "fields": { + "category": 403, + "number": 1900, + "title_narrator": null, + "title": "The Consultation of the Companions", + "description": "", + "text": "It is reported in historical works that the companions would gather and consult one another regarding major affairs of the community.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:52:53.353", + "updated_at": "2025-12-17T08:52:53.353", + "tags": [ + 539, + 540 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1878, + "fields": { + "category": 403, + "number": 1901, + "title_narrator": null, + "title": "Establishment of the Public Treasury", + "description": "", + "text": "Some historians narrate that during the early caliphate a public treasury was organized to administer charity and public funds.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:52:54.075", + "updated_at": "2025-12-17T08:52:54.075", + "tags": [ + 539, + 543 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1879, + "fields": { + "category": 404, + "number": 1910, + "title_narrator": null, + "title": "Report of the Battle Preparations", + "description": "", + "text": "Chronicles record that the believers prepared carefully before major battles, ensuring justice and discipline in their ranks.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:52:54.797", + "updated_at": "2025-12-17T08:52:54.797", + "tags": [ + 539, + 541 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1880, + "fields": { + "category": 404, + "number": 1911, + "title_narrator": null, + "title": "Mercy Shown After Victory", + "description": "", + "text": "Historical narrations mention that after some victories, clemency and mercy were shown to prisoners and civilians.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:52:55.519", + "updated_at": "2025-12-17T08:52:55.519", + "tags": [ + 539, + 542 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1881, + "fields": { + "category": 406, + "number": 1920, + "title_narrator": null, + "title": "Fatwa on Combining Prayers While Traveling", + "description": "", + "text": "A contemporary juristic council has ruled that combining prayers while traveling is permitted when hardship is present, following classical precedents.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:52:59.227", + "updated_at": "2025-12-17T08:52:59.227", + "tags": [ + 542, + 543 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1882, + "fields": { + "category": 406, + "number": 1921, + "title_narrator": null, + "title": "Fatwa on Using Local Calculations for Prayer Times", + "description": "", + "text": "Modern scholars have issued fatwas permitting the use of accurate astronomical calculations for determining prayer times.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 134, + "hadis_status_text": "Weak / Needs Review", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:52:59.950", + "updated_at": "2025-12-17T08:52:59.950", + "tags": [ + 543 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1883, + "fields": { + "category": 407, + "number": 1930, + "title_narrator": null, + "title": "Fatwa on Upholding Family Ties", + "description": "", + "text": "A fatwa committee emphasized that maintaining family ties is obligatory and that cutting off relatives without valid reason is sinful.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:53:00.673", + "updated_at": "2025-12-17T08:53:00.673", + "tags": [ + 542, + 544 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1884, + "fields": { + "category": 409, + "number": 1940, + "title_narrator": null, + "title": "Fatwa on Transparent Business Contracts", + "description": "", + "text": "Scholars have ruled that contracts must be transparent and free from deception in order to be valid in Islamic law.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:53:01.392", + "updated_at": "2025-12-17T08:53:01.392", + "tags": [ + 543 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1885, + "fields": { + "category": 411, + "number": 1950, + "title_narrator": null, + "title": "The Measure of a Heart", + "description": "", + "text": "It is said: The worth of a person is in what their heart carries of mercy and truth.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:53:03.926", + "updated_at": "2025-12-17T08:53:03.926", + "tags": [ + 542, + 545, + 546 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1886, + "fields": { + "category": 411, + "number": 1951, + "title_narrator": null, + "title": "Silence and Wisdom", + "description": "", + "text": "One of the wise said: Many people would be considered wise if they knew when to remain silent.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:53:04.645", + "updated_at": "2025-12-17T08:53:04.645", + "tags": [ + 545, + 546 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1887, + "fields": { + "category": 412, + "number": 1960, + "title_narrator": null, + "title": "Seeking Knowledge as Light", + "description": "", + "text": "It is narrated from the scholars: Knowledge is a light that guides the heart towards what benefits it.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:53:05.368", + "updated_at": "2025-12-17T08:53:05.368", + "tags": [ + 545 + ] + } +}, +{ + "model": "hadis.hadis", + "pk": 1888, + "fields": { + "category": 412, + "number": 1961, + "title_narrator": null, + "title": "Learning until the End", + "description": "", + "text": "One sage said: Continue to seek knowledge until the last day of your life, for ignorance is a darkness.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": "Authentic / Accepted", + "address": "", + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": "", + "created_at": "2025-12-17T08:53:06.086", + "updated_at": "2025-12-17T08:53:06.086", + "tags": [ + 545 + ] + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 1, + "fields": { + "hadis": 1800, + "title": "test correction", + "description": "test desc", + "translation": { + "en": "test" + }, + "created_at": "2025-12-16T11:21:23.426", + "updated_at": "2025-12-16T11:21:23.426", + "share_link": "http/www.exapmlink.com" + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 2, + "fields": { + "hadis": 1800, + "title": "Clarification on Wudu Sequence", + "description": "Some scholars differ on the exact sequence of washing body parts during ablution.", + "translation": [ + { + "lang": "en", + "text": "The order of washing in wudu should follow the established practice." + }, + { + "lang": "ar", + "text": "ترتيب غسل الأعضاء في الوضوء يجب أن يتبع السنة المثبتة." + } + ], + "created_at": "2025-12-16T16:17:08.780", + "updated_at": "2025-12-16T16:17:08.780", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 3, + "fields": { + "hadis": 1802, + "title": "Prayer Time Variations", + "description": "Different schools of thought have varying opinions on prayer times.", + "translation": [ + { + "lang": "en", + "text": "Prayer times may vary slightly depending on geographical location." + }, + { + "lang": "ar", + "text": "أوقات الصلاة قد تختلف قليلاً حسب الموقع الجغرافي." + } + ], + "created_at": "2025-12-16T16:17:09.268", + "updated_at": "2025-12-16T16:17:09.268", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 4, + "fields": { + "hadis": 1805, + "title": "Zakat Calculation Methods", + "description": "Various methods exist for calculating zakat on different types of wealth.", + "translation": [ + { + "lang": "en", + "text": "The calculation of zakat follows specific rules for different assets." + }, + { + "lang": "ar", + "text": "حساب الزكاة يتبع قواعد محددة لأنواع مختلفة من الأموال." + } + ], + "created_at": "2025-12-16T16:17:09.755", + "updated_at": "2025-12-16T16:17:09.755", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 5, + "fields": { + "hadis": 1810, + "title": "Fasting in Different Climates", + "description": "Scholars discuss fasting practices in regions with extreme daylight variations.", + "translation": [ + { + "lang": "en", + "text": "Fasting guidelines adapt to extreme daylight conditions in certain regions." + }, + { + "lang": "ar", + "text": "تتكيف إرشادات الصيام مع ظروف الإضاءة الشديدة في بعض المناطق." + } + ], + "created_at": "2025-12-16T16:17:10.244", + "updated_at": "2025-12-16T16:17:10.244", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 6, + "fields": { + "hadis": 1815, + "title": "Hajj Routes and Methods", + "description": "Multiple routes and methods for performing Hajj rituals are recognized.", + "translation": [ + { + "lang": "en", + "text": "Different schools recognize various methods for performing Hajj rituals." + }, + { + "lang": "ar", + "text": "تعترف المذاهب المختلفة بطرق متعددة لأداء مناسك الحج." + } + ], + "created_at": "2025-12-16T16:17:10.733", + "updated_at": "2025-12-16T16:17:10.733", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 7, + "fields": { + "hadis": 1820, + "title": "Ethical Conduct in Commerce", + "description": "Islamic principles of honesty and fairness in business transactions.", + "translation": [ + { + "lang": "en", + "text": "Islamic business ethics emphasize honesty and mutual fairness." + }, + { + "lang": "ar", + "text": "تؤكد أخلاقيات الأعمال الإسلامية على الصدق والإنصاف المتبادل." + } + ], + "created_at": "2025-12-16T16:17:11.223", + "updated_at": "2025-12-16T16:17:11.223", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 8, + "fields": { + "hadis": 1825, + "title": "Hadith Authentication Methods", + "description": "Scholars use various methodologies for authenticating hadith narrations.", + "translation": [ + { + "lang": "en", + "text": "Hadith scholars employ rigorous methodology in authentication." + }, + { + "lang": "ar", + "text": "يستخدم علماء الحديث منهجية صارمة في التحقق من الصحة." + } + ], + "created_at": "2025-12-16T16:17:11.714", + "updated_at": "2025-12-16T16:17:11.714", + "share_link": null + } +}, +{ + "model": "hadis.hadiscorrection", + "pk": 9, + "fields": { + "hadis": 1830, + "title": "Family Rights and Obligations", + "description": "The Islamic framework defines rights and responsibilities within families.", + "translation": [ + { + "lang": "en", + "text": "Islam defines clear rights and obligations for family members." + }, + { + "lang": "ar", + "text": "يحدد الإسلام حقوقاً والتزامات واضحة لأفراد الأسرة." + } + ], + "created_at": "2025-12-16T16:17:12.205", + "updated_at": "2025-12-16T16:17:12.205", + "share_link": null + } +} +] diff --git a/apps/hadis/fixtures/hadises2_reformatted.json b/apps/hadis/fixtures/hadises2_reformatted.json new file mode 100644 index 0000000..3aea41c --- /dev/null +++ b/apps/hadis/fixtures/hadises2_reformatted.json @@ -0,0 +1,4252 @@ +[ + { + "model": "hadis.hadistag", + "pk": 507, + "fields": { + "title": [ + { + "text": "Поклонение", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:36.034", + "updated_at": "2025-07-04T18:31:36.034" + } + }, + { + "model": "hadis.hadistag", + "pk": 508, + "fields": { + "title": [ + { + "text": "Молитва", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:36.722", + "updated_at": "2025-07-04T18:31:36.722" + } + }, + { + "model": "hadis.hadistag", + "pk": 509, + "fields": { + "title": [ + { + "text": "Пост", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:37.537", + "updated_at": "2025-07-04T18:31:37.537" + } + }, + { + "model": "hadis.hadistag", + "pk": 510, + "fields": { + "title": [ + { + "text": "Хадж", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:38.199", + "updated_at": "2025-07-04T18:31:38.199" + } + }, + { + "model": "hadis.hadistag", + "pk": 511, + "fields": { + "title": [ + { + "text": "Закят", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:38.883", + "updated_at": "2025-07-04T18:31:38.883" + } + }, + { + "model": "hadis.hadistag", + "pk": 512, + "fields": { + "title": [ + { + "text": "Хумс", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:40.144", + "updated_at": "2025-07-04T18:31:40.144" + } + }, + { + "model": "hadis.hadistag", + "pk": 513, + "fields": { + "title": [ + { + "text": "Нравственность", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:40.888", + "updated_at": "2025-07-04T18:31:40.888" + } + }, + { + "model": "hadis.hadistag", + "pk": 514, + "fields": { + "title": [ + { + "text": "Терпение", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:41.659", + "updated_at": "2025-07-04T18:31:41.659" + } + }, + { + "model": "hadis.hadistag", + "pk": 515, + "fields": { + "title": [ + { + "text": "Благодарность", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:42.487", + "updated_at": "2025-07-04T18:31:42.487" + } + }, + { + "model": "hadis.hadistag", + "pk": 516, + "fields": { + "title": [ + { + "text": "Доверие", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:43.123", + "updated_at": "2025-07-04T18:31:43.123" + } + }, + { + "model": "hadis.hadistag", + "pk": 517, + "fields": { + "title": [ + { + "text": "Богобоязненность", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:44.439", + "updated_at": "2025-07-04T18:31:44.439" + } + }, + { + "model": "hadis.hadistag", + "pk": 518, + "fields": { + "title": [ + { + "text": "Справедливость", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:45.106", + "updated_at": "2025-07-04T18:31:45.106" + } + }, + { + "model": "hadis.hadistag", + "pk": 519, + "fields": { + "title": [ + { + "text": "Фикх", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:45.785", + "updated_at": "2025-07-04T18:31:45.785" + } + }, + { + "model": "hadis.hadistag", + "pk": 520, + "fields": { + "title": [ + { + "text": "Постановления", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:46.493", + "updated_at": "2025-07-04T18:31:46.493" + } + }, + { + "model": "hadis.hadistag", + "pk": 521, + "fields": { + "title": [ + { + "text": "Халяль", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:47.278", + "updated_at": "2025-07-04T18:31:47.278" + } + }, + { + "model": "hadis.hadistag", + "pk": 522, + "fields": { + "title": [ + { + "text": "Харам", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:48.378", + "updated_at": "2025-07-04T18:31:48.378" + } + }, + { + "model": "hadis.hadistag", + "pk": 523, + "fields": { + "title": [ + { + "text": "Мустахаб", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:49.044", + "updated_at": "2025-07-04T18:31:49.044" + } + }, + { + "model": "hadis.hadistag", + "pk": 524, + "fields": { + "title": [ + { + "text": "Макрух", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:49.722", + "updated_at": "2025-07-04T18:31:49.722" + } + }, + { + "model": "hadis.hadistag", + "pk": 525, + "fields": { + "title": [ + { + "text": "Толкование", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:50.400", + "updated_at": "2025-07-04T18:31:50.400" + } + }, + { + "model": "hadis.hadistag", + "pk": 526, + "fields": { + "title": [ + { + "text": "Коран", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:51.220", + "updated_at": "2025-07-04T18:31:51.220" + } + }, + { + "model": "hadis.hadistag", + "pk": 527, + "fields": { + "title": [ + { + "text": "Аяты", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:52.341", + "updated_at": "2025-07-04T18:31:52.341" + } + }, + { + "model": "hadis.hadistag", + "pk": 528, + "fields": { + "title": [ + { + "text": "Сура", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:53.151", + "updated_at": "2025-07-04T18:31:53.151" + } + }, + { + "model": "hadis.hadistag", + "pk": 529, + "fields": { + "title": [ + { + "text": "Чтение", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:53.874", + "updated_at": "2025-07-04T18:31:53.874" + } + }, + { + "model": "hadis.hadistag", + "pk": 530, + "fields": { + "title": [ + { + "text": "Имамат", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:54.578", + "updated_at": "2025-07-04T18:31:54.578" + } + }, + { + "model": "hadis.hadistag", + "pk": 531, + "fields": { + "title": [ + { + "text": "Власть", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:55.346", + "updated_at": "2025-07-04T18:31:55.346" + } + }, + { + "model": "hadis.hadistag", + "pk": 532, + "fields": { + "title": [ + { + "text": "Непорочные", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:56.494", + "updated_at": "2025-07-04T18:31:56.494" + } + }, + { + "model": "hadis.hadistag", + "pk": 533, + "fields": { + "title": [ + { + "text": "Семья Пророка", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:57.216", + "updated_at": "2025-07-04T18:31:57.216" + } + }, + { + "model": "hadis.hadistag", + "pk": 534, + "fields": { + "title": [ + { + "text": "Мольба", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:57.983", + "updated_at": "2025-07-04T18:31:57.983" + } + }, + { + "model": "hadis.hadistag", + "pk": 535, + "fields": { + "title": [ + { + "text": "Поминание", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:58.671", + "updated_at": "2025-07-04T18:31:58.671" + } + }, + { + "model": "hadis.hadistag", + "pk": 536, + "fields": { + "title": [ + { + "text": "Прощение", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:31:59.420", + "updated_at": "2025-07-04T18:31:59.420" + } + }, + { + "model": "hadis.hadistag", + "pk": 537, + "fields": { + "title": [ + { + "text": "Восхваление", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:32:00.699", + "updated_at": "2025-07-04T18:32:00.699" + } + }, + { + "model": "hadis.hadistag", + "pk": 538, + "fields": { + "title": [ + { + "text": "Единобожие", + "language_code": "ru" + } + ], + "status": true, + "created_at": "2025-07-04T18:32:01.440", + "updated_at": "2025-07-04T18:32:01.440" + } + }, + { + "model": "hadis.hadistag", + "pk": 539, + "fields": { + "title": [ + { + "text": "history", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:13.910", + "updated_at": "2025-12-17T08:46:13.910" + } + }, + { + "model": "hadis.hadistag", + "pk": 540, + "fields": { + "title": [ + { + "text": "biography", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:14.276", + "updated_at": "2025-12-17T08:46:14.276" + } + }, + { + "model": "hadis.hadistag", + "pk": 541, + "fields": { + "title": [ + { + "text": "battle", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:14.637", + "updated_at": "2025-12-17T08:46:14.637" + } + }, + { + "model": "hadis.hadistag", + "pk": 542, + "fields": { + "title": [ + { + "text": "ethics", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:15.011", + "updated_at": "2025-12-17T08:46:15.011" + } + }, + { + "model": "hadis.hadistag", + "pk": 543, + "fields": { + "title": [ + { + "text": "jurisprudence", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:15.371", + "updated_at": "2025-12-17T08:46:15.371" + } + }, + { + "model": "hadis.hadistag", + "pk": 544, + "fields": { + "title": [ + { + "text": "family", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:15.728", + "updated_at": "2025-12-17T08:46:15.728" + } + }, + { + "model": "hadis.hadistag", + "pk": 545, + "fields": { + "title": [ + { + "text": "wisdom", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:16.086", + "updated_at": "2025-12-17T08:46:16.087" + } + }, + { + "model": "hadis.hadistag", + "pk": 546, + "fields": { + "title": [ + { + "text": "short quote", + "language_code": "en" + } + ], + "status": true, + "created_at": "2025-12-17T08:46:16.446", + "updated_at": "2025-12-17T08:46:16.446" + } + }, + + { + "model": "hadis.hadisstatus", + "pk": 126, + "fields": { + "title": [ + { + "text": "Достоверный", + "language_code": "ru" + } + ], + "color": "green", + "order": 1 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 127, + "fields": { + "title": [ + { + "text": "Хороший", + "language_code": "ru" + } + ], + "color": "blue", + "order": 2 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 128, + "fields": { + "title": [ + { + "text": "Слабый", + "language_code": "ru" + } + ], + "color": "yellow", + "order": 3 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 129, + "fields": { + "title": [ + { + "text": "Выдуманный", + "language_code": "ru" + } + ], + "color": "red", + "order": 4 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 130, + "fields": { + "title": [ + { + "text": "Прерванный", + "language_code": "ru" + } + ], + "color": "orange", + "order": 5 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 131, + "fields": { + "title": [ + { + "text": "Разорванный", + "language_code": "ru" + } + ], + "color": "purple", + "order": 6 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 132, + "fields": { + "title": [ + { + "text": "Неизвестный", + "language_code": "ru" + } + ], + "color": "gray", + "order": 7 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 133, + "fields": { + "title": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "color": "green", + "order": 1 + } + }, + { + "model": "hadis.hadisstatus", + "pk": 134, + "fields": { + "title": [ + { + "text": "Weak / Needs Review", + "language_code": "en" + } + ], + "color": "yellow", + "order": 2 + } + }, + { + "model": "hadis.hadis", + "pk": 1800, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (1)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:04.441", + "updated_at": "2025-07-04T18:33:04.441", + "tags": [ + 510, + 514, + 520 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1801, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (2)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:06.754", + "updated_at": "2025-07-04T18:33:06.754", + "tags": [ + 513, + 517, + 535 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1802, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (3)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:08.796", + "updated_at": "2025-07-04T18:33:08.796", + "tags": [ + 521, + 526, + 538 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1803, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (4)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:10.776", + "updated_at": "2025-07-04T18:33:10.776", + "tags": [ + 509, + 522, + 534 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1804, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (5)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:13.008", + "updated_at": "2025-07-04T18:33:13.008", + "tags": [ + 509, + 510, + 516 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1805, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (6)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:14.978", + "updated_at": "2025-07-04T18:33:14.978", + "tags": [ + 509, + 512, + 513 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1806, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (7)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:17.144", + "updated_at": "2025-07-04T18:33:17.144", + "tags": [ + 508, + 528, + 529 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1807, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (8)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:19.253", + "updated_at": "2025-07-04T18:33:19.253", + "tags": [ + 509, + 511, + 536 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1808, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (9)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:21.262", + "updated_at": "2025-07-04T18:33:21.262", + "tags": [ + 516, + 528, + 535 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1809, + "fields": { + "category": 330, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (10)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:23.233", + "updated_at": "2025-07-04T18:33:23.233", + "tags": [ + 515, + 516, + 528 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1810, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (1)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:25.240", + "updated_at": "2025-07-04T18:33:25.240", + "tags": [ + 507, + 531, + 533 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1811, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (2)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:27.438", + "updated_at": "2025-07-04T18:33:27.438", + "tags": [ + 518, + 521, + 525 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1812, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (3)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:29.577", + "updated_at": "2025-07-04T18:33:29.577", + "tags": [ + 509, + 512, + 521 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1813, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (4)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:31.791", + "updated_at": "2025-07-04T18:33:31.791", + "tags": [ + 514, + 520, + 522 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1814, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (5)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:33.795", + "updated_at": "2025-07-04T18:33:33.795", + "tags": [ + 516, + 522, + 538 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1815, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (6)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:36.241", + "updated_at": "2025-07-04T18:33:36.241", + "tags": [ + 511, + 524, + 525 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1816, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (7)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:38.512", + "updated_at": "2025-07-04T18:33:38.512", + "tags": [ + 507, + 531, + 535 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1817, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (8)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:40.664", + "updated_at": "2025-07-04T18:33:40.664", + "tags": [ + 507, + 521, + 530 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1818, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (9)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:42.835", + "updated_at": "2025-07-04T18:33:42.835", + "tags": [ + 516, + 518, + 530 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1819, + "fields": { + "category": 331, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Бакара (10)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:45.129", + "updated_at": "2025-07-04T18:33:45.129", + "tags": [ + 511, + 525, + 534 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1820, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (1)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:47.179", + "updated_at": "2025-07-04T18:33:47.179", + "tags": [ + 514, + 516, + 533 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1821, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (2)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:49.175", + "updated_at": "2025-07-04T18:33:49.175", + "tags": [ + 507, + 509, + 515 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1822, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (3)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:51.029", + "updated_at": "2025-07-04T18:33:51.029", + "tags": [ + 514, + 528, + 536 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1823, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (4)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:53.121", + "updated_at": "2025-07-04T18:33:53.121", + "tags": [ + 517, + 527, + 531 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1824, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (5)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:55.417", + "updated_at": "2025-07-04T18:33:55.417", + "tags": [ + 507, + 526, + 532 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1825, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (6)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:33:57.960", + "updated_at": "2025-07-04T18:33:57.960", + "tags": [ + 511, + 516, + 517 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1826, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (7)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:00.637", + "updated_at": "2025-07-04T18:34:00.637", + "tags": [ + 512, + 515, + 520 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1827, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (8)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:02.802", + "updated_at": "2025-07-04T18:34:02.802", + "tags": [ + 510, + 527, + 536 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1828, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (9)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:07.124", + "updated_at": "2025-07-04T18:34:07.124", + "tags": [ + 508, + 509, + 517 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1829, + "fields": { + "category": 332, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Толкование суры Аль Имран (10)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:09.405", + "updated_at": "2025-07-04T18:34:09.405", + "tags": [ + 514, + 528, + 534 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1830, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (1)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:11.385", + "updated_at": "2025-07-04T18:34:11.385", + "tags": [ + 517, + 524, + 536 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1831, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (2)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:13.520", + "updated_at": "2025-07-04T18:34:13.520", + "tags": [ + 507, + 533, + 537 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1832, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (3)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:16.292", + "updated_at": "2025-07-04T18:34:16.292", + "tags": [ + 514, + 525, + 538 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1833, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (4)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:18.934", + "updated_at": "2025-07-04T18:34:18.934", + "tags": [ + 522, + 529, + 531 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1834, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (5)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:24.322", + "updated_at": "2025-07-04T18:34:24.322", + "tags": [ + 512, + 520, + 535 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1835, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (6)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:26.539", + "updated_at": "2025-07-04T18:34:26.539", + "tags": [ + 512, + 532, + 533 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1836, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (7)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:28.610", + "updated_at": "2025-07-04T18:34:28.610", + "tags": [ + 517, + 519, + 526 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1837, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (8)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:30.634", + "updated_at": "2025-07-04T18:34:30.634", + "tags": [ + 524, + 527, + 535 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1838, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (9)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:32.831", + "updated_at": "2025-07-04T18:34:32.831", + "tags": [ + 515, + 519, + 538 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1839, + "fields": { + "category": 333, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о молитве (10)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:35.239", + "updated_at": "2025-07-04T18:34:35.239", + "tags": [ + 513, + 527, + 528 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1840, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (1)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:37.573", + "updated_at": "2025-07-04T18:34:37.573", + "tags": [ + 509, + 511, + 531 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1841, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (2)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:43.509", + "updated_at": "2025-07-04T18:34:43.509", + "tags": [ + 516, + 525, + 527 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1842, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (3)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:51.461", + "updated_at": "2025-07-04T18:34:51.461", + "tags": [ + 510, + 516, + 534 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1843, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (4)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:53.805", + "updated_at": "2025-07-04T18:34:53.805", + "tags": [ + 512, + 514, + 522 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1844, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (5)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:55.725", + "updated_at": "2025-07-04T18:34:55.725", + "tags": [ + 525, + 530, + 532 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1845, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (6)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:57.641", + "updated_at": "2025-07-04T18:34:57.641", + "tags": [ + 520, + 525, + 529 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1846, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (7)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 132, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:34:59.557", + "updated_at": "2025-07-04T18:34:59.557", + "tags": [ + 522, + 527, + 536 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1847, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (8)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 126, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:35:01.450", + "updated_at": "2025-07-04T18:35:01.450", + "tags": [ + 508, + 526, + 528 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1848, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (9)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 131, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:35:03.443", + "updated_at": "2025-07-04T18:35:03.443", + "tags": [ + 509, + 511, + 515 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1849, + "fields": { + "category": 334, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство поста и его духовные плоды - Аяты о посте (10)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً.\n\nإن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه.\n\nيا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 127, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:35:05.357", + "updated_at": "2025-07-04T18:35:05.357", + "tags": [ + 517, + 520, + 528 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1850, + "fields": { + "category": 335, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о закяте (1)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 130, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:35:07.197", + "updated_at": "2025-07-04T18:35:07.197", + "tags": [ + 508, + 521, + 529 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1851, + "fields": { + "category": 335, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о закяте (2)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 128, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:35:09.126", + "updated_at": "2025-07-04T18:35:09.126", + "tags": [ + 507, + 513, + 534 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1852, + "fields": { + "category": 335, + "number": 1, + "title_narrator": [], + "title": [ + { + "text": "Достоинство молитвы и ее место в религии - Аяты о закяте (3)", + "language_code": "ru" + } + ], + "description": [], + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 129, + "hadis_status_text": [], + "address": [], + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "language_code": "ru" + } + ], + "created_at": "2025-07-04T18:35:11.121", + "updated_at": "2025-07-04T18:35:11.121", + "tags": [ + 524, + 530, + 532 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1877, + "fields": { + "category": 403, + "number": 1900, + "title_narrator": [], + "title": [ + { + "text": "The Consultation of the Companions", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "It is reported in historical works that the companions would gather and consult one another regarding major affairs of the community.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:52:53.353", + "updated_at": "2025-12-17T08:52:53.353", + "tags": [ + 539, + 540 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1878, + "fields": { + "category": 403, + "number": 1901, + "title_narrator": [], + "title": [ + { + "text": "Establishment of the Public Treasury", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "Some historians narrate that during the early caliphate a public treasury was organized to administer charity and public funds.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:52:54.075", + "updated_at": "2025-12-17T08:52:54.075", + "tags": [ + 539, + 543 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1879, + "fields": { + "category": 404, + "number": 1910, + "title_narrator": [], + "title": [ + { + "text": "Report of the Battle Preparations", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "Chronicles record that the believers prepared carefully before major battles, ensuring justice and discipline in their ranks.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:52:54.797", + "updated_at": "2025-12-17T08:52:54.797", + "tags": [ + 539, + 541 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1880, + "fields": { + "category": 404, + "number": 1911, + "title_narrator": [], + "title": [ + { + "text": "Mercy Shown After Victory", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "Historical narrations mention that after some victories, clemency and mercy were shown to prisoners and civilians.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:52:55.519", + "updated_at": "2025-12-17T08:52:55.519", + "tags": [ + 539, + 542 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1881, + "fields": { + "category": 406, + "number": 1920, + "title_narrator": [], + "title": [ + { + "text": "Fatwa on Combining Prayers While Traveling", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "A contemporary juristic council has ruled that combining prayers while traveling is permitted when hardship is present, following classical precedents.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:52:59.227", + "updated_at": "2025-12-17T08:52:59.227", + "tags": [ + 542, + 543 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1882, + "fields": { + "category": 406, + "number": 1921, + "title_narrator": [], + "title": [ + { + "text": "Fatwa on Using Local Calculations for Prayer Times", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "Modern scholars have issued fatwas permitting the use of accurate astronomical calculations for determining prayer times.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 134, + "hadis_status_text": [ + { + "text": "Weak / Needs Review", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:52:59.950", + "updated_at": "2025-12-17T08:52:59.950", + "tags": [ + 543 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1883, + "fields": { + "category": 407, + "number": 1930, + "title_narrator": [], + "title": [ + { + "text": "Fatwa on Upholding Family Ties", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "A fatwa committee emphasized that maintaining family ties is obligatory and that cutting off relatives without valid reason is sinful.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:53:00.673", + "updated_at": "2025-12-17T08:53:00.673", + "tags": [ + 542, + 544 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1884, + "fields": { + "category": 409, + "number": 1940, + "title_narrator": [], + "title": [ + { + "text": "Fatwa on Transparent Business Contracts", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "Scholars have ruled that contracts must be transparent and free from deception in order to be valid in Islamic law.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:53:01.392", + "updated_at": "2025-12-17T08:53:01.392", + "tags": [ + 543 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1885, + "fields": { + "category": 411, + "number": 1950, + "title_narrator": [], + "title": [ + { + "text": "The Measure of a Heart", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "It is said: The worth of a person is in what their heart carries of mercy and truth.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:53:03.926", + "updated_at": "2025-12-17T08:53:03.926", + "tags": [ + 542, + 545, + 546 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1886, + "fields": { + "category": 411, + "number": 1951, + "title_narrator": [], + "title": [ + { + "text": "Silence and Wisdom", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "One of the wise said: Many people would be considered wise if they knew when to remain silent.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:53:04.645", + "updated_at": "2025-12-17T08:53:04.645", + "tags": [ + 545, + 546 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1887, + "fields": { + "category": 412, + "number": 1960, + "title_narrator": [], + "title": [ + { + "text": "Seeking Knowledge as Light", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "It is narrated from the scholars: Knowledge is a light that guides the heart towards what benefits it.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:53:05.368", + "updated_at": "2025-12-17T08:53:05.368", + "tags": [ + 545 + ] + } + }, + { + "model": "hadis.hadis", + "pk": 1888, + "fields": { + "category": 412, + "number": 1961, + "title_narrator": [], + "title": [ + { + "text": "Learning until the End", + "language_code": "en" + } + ], + "description": [ + { + "text": "", + "language_code": "en" + } + ], + "text": "One sage said: Continue to seek knowledge until the last day of your life, for ignorance is a darkness.", + "translation": { + "en": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah s religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "fa": "رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود، اعمال دیگر نیز پذیرفته می‌شود و اگر رد شود، اعمال دیگر نیز رد می‌شود. نماز اولین چیزی است که بنده در روز قیامت به خاطر آن مورد بازخواست قرار می‌گیرد و اگر سالم باشد، سایر اعمالش نیز سالم خواهد بود و اگر فاسد باشد، سایر اعمالش نیز فاسد می‌شود.\n\nنماز معراج مؤمن است، قربانی هر پرهیزگار است و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش اقامه کند و حدودش را رعایت کند، خداوند او را به درجه صالحان می‌رساند. و هر کس آن را سبک بشمارد، ضایع کند و ترک کند، دین خدا را سبک شمرده و از اسلام بهره‌ای ندارد.\n\nهمانا خداوند متعال پنج نماز را در شبانه‌روز فرض کرده و برای هر نمازی زمانی معین قرار داده است. هر کس آنها را در وقتشان بخواند و رکوع و سجود و خشوع آنها را کامل کند، نور، حجت و نجات خواهد بود. برای او در روز قیامت.»" + }, + "status": true, + "hadis_status": 133, + "hadis_status_text": [ + { + "text": "Authentic / Accepted", + "language_code": "en" + } + ], + "address": [ + { + "text": "", + "language_code": "en" + } + ], + "links": {}, + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "explanation": [ + { + "text": "", + "language_code": "en" + } + ], + "created_at": "2025-12-17T08:53:06.086", + "updated_at": "2025-12-17T08:53:06.086", + "tags": [ + 545 + ] + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 1, + "fields": { + "hadis": 1800, + "title": [ + { + "text": "test correction", + "language_code": "en" + } + ], + "description": [ + { + "text": "test desc", + "language_code": "en" + } + ], + "translation": { + "en": "test" + }, + "created_at": "2025-12-16T11:21:23.426", + "updated_at": "2025-12-16T11:21:23.426", + "share_link": "http/www.exapmlink.com" + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 2, + "fields": { + "hadis": 1800, + "title": [ + { + "text": "Clarification on Wudu Sequence", + "language_code": "en" + } + ], + "description": [ + { + "text": "Some scholars differ on the exact sequence of washing body parts during ablution.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "The order of washing in wudu should follow the established practice." + }, + { + "lang": "ar", + "text": "ترتيب غسل الأعضاء في الوضوء يجب أن يتبع السنة المثبتة." + } + ], + "created_at": "2025-12-16T16:17:08.780", + "updated_at": "2025-12-16T16:17:08.780", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 3, + "fields": { + "hadis": 1802, + "title": [ + { + "text": "Prayer Time Variations", + "language_code": "en" + } + ], + "description": [ + { + "text": "Different schools of thought have varying opinions on prayer times.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "Prayer times may vary slightly depending on geographical location." + }, + { + "lang": "ar", + "text": "أوقات الصلاة قد تختلف قليلاً حسب الموقع الجغرافي." + } + ], + "created_at": "2025-12-16T16:17:09.268", + "updated_at": "2025-12-16T16:17:09.268", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 4, + "fields": { + "hadis": 1805, + "title": [ + { + "text": "Zakat Calculation Methods", + "language_code": "en" + } + ], + "description": [ + { + "text": "Various methods exist for calculating zakat on different types of wealth.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "The calculation of zakat follows specific rules for different assets." + }, + { + "lang": "ar", + "text": "حساب الزكاة يتبع قواعد محددة لأنواع مختلفة من الأموال." + } + ], + "created_at": "2025-12-16T16:17:09.755", + "updated_at": "2025-12-16T16:17:09.755", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 5, + "fields": { + "hadis": 1810, + "title": [ + { + "text": "Fasting in Different Climates", + "language_code": "en" + } + ], + "description": [ + { + "text": "Scholars discuss fasting practices in regions with extreme daylight variations.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "Fasting guidelines adapt to extreme daylight conditions in certain regions." + }, + { + "lang": "ar", + "text": "تتكيف إرشادات الصيام مع ظروف الإضاءة الشديدة في بعض المناطق." + } + ], + "created_at": "2025-12-16T16:17:10.244", + "updated_at": "2025-12-16T16:17:10.244", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 6, + "fields": { + "hadis": 1815, + "title": [ + { + "text": "Hajj Routes and Methods", + "language_code": "en" + } + ], + "description": [ + { + "text": "Multiple routes and methods for performing Hajj rituals are recognized.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "Different schools recognize various methods for performing Hajj rituals." + }, + { + "lang": "ar", + "text": "تعترف المذاهب المختلفة بطرق متعددة لأداء مناسك الحج." + } + ], + "created_at": "2025-12-16T16:17:10.733", + "updated_at": "2025-12-16T16:17:10.733", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 7, + "fields": { + "hadis": 1820, + "title": [ + { + "text": "Ethical Conduct in Commerce", + "language_code": "en" + } + ], + "description": [ + { + "text": "Islamic principles of honesty and fairness in business transactions.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "Islamic business ethics emphasize honesty and mutual fairness." + }, + { + "lang": "ar", + "text": "تؤكد أخلاقيات الأعمال الإسلامية على الصدق والإنصاف المتبادل." + } + ], + "created_at": "2025-12-16T16:17:11.223", + "updated_at": "2025-12-16T16:17:11.223", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 8, + "fields": { + "hadis": 1825, + "title": [ + { + "text": "Hadith Authentication Methods", + "language_code": "en" + } + ], + "description": [ + { + "text": "Scholars use various methodologies for authenticating hadith narrations.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "Hadith scholars employ rigorous methodology in authentication." + }, + { + "lang": "ar", + "text": "يستخدم علماء الحديث منهجية صارمة في التحقق من الصحة." + } + ], + "created_at": "2025-12-16T16:17:11.714", + "updated_at": "2025-12-16T16:17:11.714", + "share_link": null + } + }, + { + "model": "hadis.hadiscorrection", + "pk": 9, + "fields": { + "hadis": 1830, + "title": [ + { + "text": "Family Rights and Obligations", + "language_code": "en" + } + ], + "description": [ + { + "text": "The Islamic framework defines rights and responsibilities within families.", + "language_code": "en" + } + ], + "translation": [ + { + "lang": "en", + "text": "Islam defines clear rights and obligations for family members." + }, + { + "lang": "ar", + "text": "يحدد الإسلام حقوقاً والتزامات واضحة لأفراد الأسرة." + } + ], + "created_at": "2025-12-16T16:17:12.205", + "updated_at": "2025-12-16T16:17:12.205", + "share_link": null + } + } +] \ No newline at end of file diff --git a/apps/hadis/fixtures/new_categories.json b/apps/hadis/fixtures/new_categories.json new file mode 100644 index 0000000..9114359 --- /dev/null +++ b/apps/hadis/fixtures/new_categories.json @@ -0,0 +1,1630 @@ +[ + { + "model": "hadis.hadiscategory", + "pk": 324, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Толкование Корана", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-3", + "lft": 1, + "rght": 8, + "tree_id": 1, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 325, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Аяты постановлений", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "hadis/xmind_files/category_325_Аяты_постановлений.xmind", + "slug": "-19", + "lft": 1, + "rght": 8, + "tree_id": 2, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 326, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Коранические истории", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "hadis/xmind_files/category_326_Коранические_истории.xmind", + "slug": "-34", + "lft": 1, + "rght": 6, + "tree_id": 3, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 327, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Достоинства сур", + "language_code": "ru" + } + ], + "description": [], + "order": 4, + "xmind_file": "hadis/xmind_files/category_327_Достоинства_сур.xmind", + "slug": "-46", + "lft": 1, + "rght": 2, + "tree_id": 4, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 328, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Чудеса Корана", + "language_code": "ru" + } + ], + "description": [], + "order": 5, + "xmind_file": "hadis/xmind_files/category_328_Чудеса_Корана.xmind", + "slug": "-48", + "lft": 1, + "rght": 2, + "tree_id": 5, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 329, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Коранические науки", + "language_code": "ru" + } + ], + "description": [], + "order": 6, + "xmind_file": "", + "slug": "-55", + "lft": 1, + "rght": 2, + "tree_id": 6, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 330, + "fields": { + "parent": 324, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Толкование суры Аль-Фатиха", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "", + "lft": 2, + "rght": 3, + "tree_id": 1, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 331, + "fields": { + "parent": 324, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Толкование суры Аль-Бакара", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-16", + "lft": 4, + "rght": 5, + "tree_id": 1, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 332, + "fields": { + "parent": 324, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Толкование суры Аль Имран", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-32", + "lft": 6, + "rght": 7, + "tree_id": 1, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 333, + "fields": { + "parent": 325, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Аяты о молитве", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-15", + "lft": 2, + "rght": 3, + "tree_id": 2, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 334, + "fields": { + "parent": 325, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Аяты о посте", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-22", + "lft": 4, + "rght": 5, + "tree_id": 2, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 335, + "fields": { + "parent": 325, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Аяты о закяте", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-33", + "lft": 6, + "rght": 7, + "tree_id": 2, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 336, + "fields": { + "parent": 326, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Истории пророков", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-14", + "lft": 2, + "rght": 3, + "tree_id": 3, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 337, + "fields": { + "parent": 326, + "sect": 20, + "source_type": "quran", + "title": [ + { + "text": "Истории праведников", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-21", + "lft": 4, + "rght": 5, + "tree_id": 3, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 338, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Книга очищения", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "hadis/xmind_files/category_338_Книга_очищения.xmind", + "slug": "-1", + "lft": 1, + "rght": 8, + "tree_id": 7, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 339, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Книга молитвы", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "hadis/xmind_files/category_339_Книга_молитвы.xmind", + "slug": "-8", + "lft": 1, + "rght": 8, + "tree_id": 8, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 340, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Книга поста", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "hadis/xmind_files/category_340_Книга_поста.xmind", + "slug": "-36", + "lft": 1, + "rght": 2, + "tree_id": 9, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 341, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Книга хаджа", + "language_code": "ru" + } + ], + "description": [], + "order": 4, + "xmind_file": "hadis/xmind_files/category_341_Книга_хаджа.xmind", + "slug": "-47", + "lft": 1, + "rght": 2, + "tree_id": 10, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 342, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Книга закята", + "language_code": "ru" + } + ], + "description": [], + "order": 5, + "xmind_file": "", + "slug": "-51", + "lft": 1, + "rght": 2, + "tree_id": 11, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 343, + "fields": { + "parent": null, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Книга нравственности", + "language_code": "ru" + } + ], + "description": [], + "order": 6, + "xmind_file": "hadis/xmind_files/category_343_Книга_нравственности.xmind", + "slug": "-54", + "lft": 1, + "rght": 6, + "tree_id": 12, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 344, + "fields": { + "parent": 338, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Омовение", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-9", + "lft": 2, + "rght": 3, + "tree_id": 7, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 345, + "fields": { + "parent": 338, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Полное омовение", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-20", + "lft": 4, + "rght": 5, + "tree_id": 7, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 346, + "fields": { + "parent": 338, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Сухое омовение", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-35", + "lft": 6, + "rght": 7, + "tree_id": 7, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 347, + "fields": { + "parent": 339, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Времена молитв", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-2", + "lft": 2, + "rght": 3, + "tree_id": 8, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 348, + "fields": { + "parent": 339, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Направление киблы", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-25", + "lft": 4, + "rght": 5, + "tree_id": 8, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 349, + "fields": { + "parent": 339, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Коллективная молитва", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-41", + "lft": 6, + "rght": 7, + "tree_id": 8, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 350, + "fields": { + "parent": 343, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Терпение и благодарность", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-7", + "lft": 2, + "rght": 3, + "tree_id": 12, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 351, + "fields": { + "parent": 343, + "sect": 20, + "source_type": "hadith", + "title": [ + { + "text": "Справедливость и честность", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-28", + "lft": 4, + "rght": 5, + "tree_id": 12, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 352, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Толкование Корана", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-13", + "lft": 1, + "rght": 8, + "tree_id": 13, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 353, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Аяты постановлений", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-17", + "lft": 1, + "rght": 8, + "tree_id": 14, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 354, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Коранические истории", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "hadis/xmind_files/category_354_Коранические_истории.xmind", + "slug": "-39", + "lft": 1, + "rght": 6, + "tree_id": 15, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 355, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Достоинства сур", + "language_code": "ru" + } + ], + "description": [], + "order": 4, + "xmind_file": "hadis/xmind_files/category_355_Достоинства_сур.xmind", + "slug": "-43", + "lft": 1, + "rght": 2, + "tree_id": 16, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 356, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Чудеса Корана", + "language_code": "ru" + } + ], + "description": [], + "order": 5, + "xmind_file": "hadis/xmind_files/category_356_Чудеса_Корана.xmind", + "slug": "-49", + "lft": 1, + "rght": 2, + "tree_id": 17, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 357, + "fields": { + "parent": null, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Коранические науки", + "language_code": "ru" + } + ], + "description": [], + "order": 6, + "xmind_file": "", + "slug": "-50", + "lft": 1, + "rght": 2, + "tree_id": 18, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 358, + "fields": { + "parent": 352, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Толкование суры Аль-Фатиха", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-12", + "lft": 2, + "rght": 3, + "tree_id": 13, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 359, + "fields": { + "parent": 352, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Толкование суры Аль-Бакара", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-29", + "lft": 4, + "rght": 5, + "tree_id": 13, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 360, + "fields": { + "parent": 352, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Толкование суры Аль Имран", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-37", + "lft": 6, + "rght": 7, + "tree_id": 13, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 361, + "fields": { + "parent": 353, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Аяты о молитве", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-5", + "lft": 2, + "rght": 3, + "tree_id": 14, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 362, + "fields": { + "parent": 353, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Аяты о посте", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-27", + "lft": 4, + "rght": 5, + "tree_id": 14, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 363, + "fields": { + "parent": 353, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Аяты о закяте", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-38", + "lft": 6, + "rght": 7, + "tree_id": 14, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 364, + "fields": { + "parent": 354, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Истории пророков", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-4", + "lft": 2, + "rght": 3, + "tree_id": 15, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 365, + "fields": { + "parent": 354, + "sect": 21, + "source_type": "quran", + "title": [ + { + "text": "Истории праведников", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-23", + "lft": 4, + "rght": 5, + "tree_id": 15, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 366, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Книга очищения", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "hadis/xmind_files/category_366_Книга_очищения.xmind", + "slug": "-10", + "lft": 1, + "rght": 8, + "tree_id": 19, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 367, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Книга молитвы", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "hadis/xmind_files/category_367_Книга_молитвы.xmind", + "slug": "-18", + "lft": 1, + "rght": 8, + "tree_id": 20, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 368, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Книга поста", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "hadis/xmind_files/category_368_Книга_поста.xmind", + "slug": "-42", + "lft": 1, + "rght": 2, + "tree_id": 21, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 369, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Книга хаджа", + "language_code": "ru" + } + ], + "description": [], + "order": 4, + "xmind_file": "", + "slug": "-45", + "lft": 1, + "rght": 2, + "tree_id": 22, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 370, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Книга закята", + "language_code": "ru" + } + ], + "description": [], + "order": 5, + "xmind_file": "", + "slug": "-44", + "lft": 1, + "rght": 2, + "tree_id": 23, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 371, + "fields": { + "parent": null, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Книга нравственности", + "language_code": "ru" + } + ], + "description": [], + "order": 6, + "xmind_file": "", + "slug": "-53", + "lft": 1, + "rght": 6, + "tree_id": 24, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 372, + "fields": { + "parent": 366, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Омовение", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-11", + "lft": 2, + "rght": 3, + "tree_id": 19, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 373, + "fields": { + "parent": 366, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Полное омовение", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-31", + "lft": 4, + "rght": 5, + "tree_id": 19, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 374, + "fields": { + "parent": 366, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Сухое омовение", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-26", + "lft": 6, + "rght": 7, + "tree_id": 19, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 375, + "fields": { + "parent": 367, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Времена молитв", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-6", + "lft": 2, + "rght": 3, + "tree_id": 20, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 376, + "fields": { + "parent": 367, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Направление киблы", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-30", + "lft": 4, + "rght": 5, + "tree_id": 20, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 377, + "fields": { + "parent": 367, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Коллективная молитва", + "language_code": "ru" + } + ], + "description": [], + "order": 3, + "xmind_file": "", + "slug": "-40", + "lft": 6, + "rght": 7, + "tree_id": 20, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 378, + "fields": { + "parent": 371, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Терпение и благодарность", + "language_code": "ru" + } + ], + "description": [], + "order": 1, + "xmind_file": "", + "slug": "-56", + "lft": 2, + "rght": 3, + "tree_id": 24, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 379, + "fields": { + "parent": 371, + "sect": 21, + "source_type": "hadith", + "title": [ + { + "text": "Справедливость и честность", + "language_code": "ru" + } + ], + "description": [], + "order": 2, + "xmind_file": "", + "slug": "-24", + "lft": 4, + "rght": 5, + "tree_id": 24, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 402, + "fields": { + "parent": null, + "sect": 21, + "source_type": "history", + "title": [ + { + "text": "History of Islam", + "language_code": "en" + } + ], + "description": [ + { + "text": "High-level historical themes related to early Islamic history.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "history-of-islam", + "lft": 1, + "rght": 6, + "tree_id": 25, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 403, + "fields": { + "parent": 402, + "sect": 21, + "source_type": "history", + "title": [ + { + "text": "Early Caliphate", + "language_code": "en" + } + ], + "description": [ + { + "text": "Events and reports from the period of the first caliphs.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "early-caliphate", + "lft": 2, + "rght": 3, + "tree_id": 25, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 404, + "fields": { + "parent": 402, + "sect": 21, + "source_type": "history", + "title": [ + { + "text": "Battles and Expeditions", + "language_code": "en" + } + ], + "description": [ + { + "text": "Key battles and expeditions in early Islamic history.", + "language_code": "en" + } + ], + "order": 2, + "xmind_file": "", + "slug": "battles-and-expeditions", + "lft": 4, + "rght": 5, + "tree_id": 25, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 405, + "fields": { + "parent": null, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "Contemporary Fatwas", + "language_code": "en" + } + ], + "description": [ + { + "text": "Modern juristic responses to contemporary questions.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "contemporary-fatwas", + "lft": 1, + "rght": 6, + "tree_id": 26, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 406, + "fields": { + "parent": 405, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "Worship and Rituals", + "language_code": "en" + } + ], + "description": [ + { + "text": "Fatwas about prayer, fasting and other acts of worship.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "worship-and-rituals", + "lft": 2, + "rght": 3, + "tree_id": 26, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 407, + "fields": { + "parent": 405, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "Family Issues", + "language_code": "en" + } + ], + "description": [ + { + "text": "Fatwas regarding marriage, divorce and family obligations.", + "language_code": "en" + } + ], + "order": 2, + "xmind_file": "", + "slug": "family-issues", + "lft": 4, + "rght": 5, + "tree_id": 26, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 408, + "fields": { + "parent": null, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "Financial Fatwas", + "language_code": "en" + } + ], + "description": [ + { + "text": "Juristic rulings about trade, contracts and modern finance.", + "language_code": "en" + } + ], + "order": 2, + "xmind_file": "", + "slug": "financial-fatwas", + "lft": 1, + "rght": 4, + "tree_id": 27, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 409, + "fields": { + "parent": 408, + "sect": 21, + "source_type": "fatwa", + "title": [ + { + "text": "Trade and Contracts", + "language_code": "en" + } + ], + "description": [ + { + "text": "Fatwas related to buying, selling and contractual agreements.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "trade-and-contracts", + "lft": 2, + "rght": 3, + "tree_id": 27, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 410, + "fields": { + "parent": null, + "sect": 20, + "source_type": "quote", + "title": [ + { + "text": "Wisdom Quotes", + "language_code": "en" + } + ], + "description": [ + { + "text": "Short wise sayings and inspirational quotes.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "wisdom-quotes", + "lft": 1, + "rght": 6, + "tree_id": 28, + "level": 0 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 411, + "fields": { + "parent": 410, + "sect": 20, + "source_type": "quote", + "title": [ + { + "text": "Short Wisdom", + "language_code": "en" + } + ], + "description": [ + { + "text": "Very short, memorable quotes on character and behavior.", + "language_code": "en" + } + ], + "order": 1, + "xmind_file": "", + "slug": "short-wisdom", + "lft": 2, + "rght": 3, + "tree_id": 28, + "level": 1 + } + }, + { + "model": "hadis.hadiscategory", + "pk": 412, + "fields": { + "parent": 410, + "sect": 20, + "source_type": "quote", + "title": [ + { + "text": "On Knowledge", + "language_code": "en" + } + ], + "description": [ + { + "text": "Quotes emphasizing the virtue of knowledge and learning.", + "language_code": "en" + } + ], + "order": 2, + "xmind_file": "", + "slug": "on-knowledge", + "lft": 4, + "rght": 5, + "tree_id": 28, + "level": 1 + } + }, + { + "model": "hadis.hadissect", + "pk": 20, + "fields": { + "sect_type": "shia", + "title": [ + { + "text": "Шииты-двунадесятники", + "language_code": "ru" + } + ], + "description": [], + "is_active": true, + "order": 1 + } + }, + { + "model": "hadis.hadissect", + "pk": 21, + "fields": { + "sect_type": "sunni", + "title": [ + { + "text": "Сунниты", + "language_code": "ru" + } + ], + "description": [], + "is_active": true, + "order": 2 + } + } + ] \ No newline at end of file diff --git a/apps/hadis/fixtures/references_backup.json b/apps/hadis/fixtures/references_backup.json new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/fixtures/reformat_data.py b/apps/hadis/fixtures/reformat_data.py new file mode 100644 index 0000000..747359a --- /dev/null +++ b/apps/hadis/fixtures/reformat_data.py @@ -0,0 +1,115 @@ +import json +import re +import sys + +def detect_language(text): + """ + Detect language code based on text content. + - Cyrillic characters -> 'ru' + - Arabic/Persian characters -> 'fa' + - Default -> 'en' + """ + if not text or not isinstance(text, str): + return 'en' + + # Check for Cyrillic (Russian) + if re.search(r'[а-яА-Я]', text): + return 'ru' + + # Check for Arabic/Persian script + if re.search(r'[\u0600-\u06FF]', text): + return 'fa' + + # Default to English + return 'en' + +def reformat_data(input_file, output_file): + print(f"📖 Reading {input_file}...") + + try: + with open(input_file, 'r', encoding='utf-8') as f: + data = json.load(f) + except FileNotFoundError: + print(f"❌ Error: File '{input_file}' not found.") + return + except json.JSONDecodeError as e: + print(f"❌ Error: Failed to decode JSON. {e}") + return + + processed_count = 0 + + # Configuration based on your request + TARGETS = { + 'hadis.narratorlayer': [ + 'name', + 'description' + ], + 'hadis.transmitters': [ + 'full_name', + 'kunya', + 'known_as', + 'nickname', + 'origin', + 'lived_in', + 'died_in', + 'description' + ], + 'hadis.transmitteropinion': [ + 'scholar_name', + 'opinion_text' + ], + 'hadis.transmitteroriginaltext': [ + 'title', + 'text' + ], + } + + for record in data: + model = record.get('model') + if model in TARGETS: + fields = record.get('fields', {}) + target_fields = TARGETS[model] + + for field in target_fields: + if field in fields: + original_value = fields[field] + + # Case 1: Value is None/Null -> Empty List + if original_value is None: + fields[field] = [] + continue + + # Case 2: Value is String -> Convert to JSON Format + if isinstance(original_value, str): + # Detect language + lang_code = detect_language(original_value) + + # Reformat + fields[field] = [ + { + "text": original_value, + "language_code": lang_code + } + ] + + # Case 3: Already a list -> Skip + elif isinstance(original_value, list): + continue + + processed_count += 1 + + print(f"✅ Processed {processed_count} records.") + + try: + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print(f"💾 Saved reformatted data to: {output_file}") + except Exception as e: + print(f"❌ Error writing output file: {e}") + +if __name__ == "__main__": + # Input/Output filenames + INPUT_FILE = "transmitters_backup.json" + OUTPUT_FILE = "transmitters_reformatted.json" + + reformat_data(INPUT_FILE, OUTPUT_FILE) diff --git a/apps/hadis/fixtures/transmitters_backup.json b/apps/hadis/fixtures/transmitters_backup.json new file mode 100644 index 0000000..b753817 --- /dev/null +++ b/apps/hadis/fixtures/transmitters_backup.json @@ -0,0 +1,950 @@ +[ +{ + "model": "hadis.narratorlayer", + "pk": 6, + "fields": { + "name": "Sahaba (Companions)", + "number": 1, + "description": "The companions of Prophet Muhammad (PBUH) who heard directly from him.", + "slug": "sahaba-companions", + "created_at": "2025-12-16T15:52:28.561", + "updated_at": "2025-12-16T15:52:28.561" + } +}, +{ + "model": "hadis.narratorlayer", + "pk": 7, + "fields": { + "name": "Tabi'un (Successors)", + "number": 2, + "description": "Narrators who lived after the Prophet's era and heard from the companions.", + "slug": "tabiun-successors", + "created_at": "2025-12-16T15:52:28.959", + "updated_at": "2025-12-16T15:52:28.959" + } +}, +{ + "model": "hadis.narratorlayer", + "pk": 8, + "fields": { + "name": "Taba' Tabi'in (Followers of Successors)", + "number": 3, + "description": "Narrators who heard from the Successors.", + "slug": "taba-tabiin-followers-of-successors", + "created_at": "2025-12-16T15:52:29.359", + "updated_at": "2025-12-16T15:52:29.359" + } +}, +{ + "model": "hadis.narratorlayer", + "pk": 9, + "fields": { + "name": "Later Generations", + "number": 4, + "description": "Narrators from later Islamic centuries, known for their scholarship.", + "slug": "later-generations", + "created_at": "2025-12-16T15:52:29.759", + "updated_at": "2025-12-16T15:52:29.759" + } +}, +{ + "model": "hadis.narratorlayer", + "pk": 10, + "fields": { + "name": "Modern Era Scholars", + "number": 5, + "description": "Contemporary scholars who have specialized knowledge of hadith narration.", + "slug": "modern-era-scholars", + "created_at": "2025-12-16T15:52:30.157", + "updated_at": "2025-12-16T15:52:30.157" + } +}, +{ + "model": "hadis.transmitters", + "pk": 71, + "fields": { + "full_name": "Abu Hurayrah", + "kunya": "Abu Hurayrah", + "known_as": "Abd al-Rahman ibn Sakhr", + "nickname": "Father of the Kitten", + "slug": "abu-hurayrah", + "origin": "Yamama, Arabia", + "lived_in": "Medina, Syria", + "died_in": "Medina", + "birth_year_hijri": 7, + "death_year_hijri": 58, + "age_at_death": 78, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "One of the most prolific hadith narrators, reported 5,374 ahadith.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:30.856", + "updated_at": "2025-12-17T13:33:19.654" + } +}, +{ + "model": "hadis.transmitters", + "pk": 72, + "fields": { + "full_name": "Aisha bint Abu Bakr", + "kunya": "Umm al-Mu'minin", + "known_as": "Mother of the Believers", + "nickname": "Al-Siddiqa", + "slug": "aisha-bint-abu-bakr", + "origin": "Medina", + "lived_in": "Medina", + "died_in": "Medina", + "birth_year_hijri": null, + "death_year_hijri": 58, + "age_at_death": 66, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Wife of the Prophet Muhammad (PBUH), a major source of hadith about daily life.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:31.258", + "updated_at": "2025-12-17T13:33:19.843" + } +}, +{ + "model": "hadis.transmitters", + "pk": 73, + "fields": { + "full_name": "Jabir ibn Abdullah al-Ansari", + "kunya": "Abu Abdullah", + "known_as": "Jabir", + "nickname": "Al-Ansari", + "slug": "jabir-ibn-abdullah-al-ansari", + "origin": "Medina", + "lived_in": "Medina", + "died_in": "Medina", + "birth_year_hijri": 10, + "death_year_hijri": 74, + "age_at_death": 94, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "One of the long-lived companions, reported numerous ahadith on various topics.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:31.987", + "updated_at": "2025-12-17T13:33:20.599" + } +}, +{ + "model": "hadis.transmitters", + "pk": 74, + "fields": { + "full_name": "Imam Malik ibn Anas", + "kunya": "Abu Abdullah", + "known_as": "Malik", + "nickname": "Imam of Imams", + "slug": "imam-malik-ibn-anas", + "origin": "Medina", + "lived_in": "Medina", + "died_in": "Medina", + "birth_year_hijri": 93, + "death_year_hijri": 179, + "age_at_death": 86, + "generation": 3, + "reliability": "very_reliable", + "madhhab": "maliki", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Founder of the Maliki school of Islamic jurisprudence, compiler of Al-Muwatta.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:32.383", + "updated_at": "2025-12-17T13:33:20.412" + } +}, +{ + "model": "hadis.transmitters", + "pk": 75, + "fields": { + "full_name": "Al-Qasim ibn Muhammad", + "kunya": "Abu Muhammad", + "known_as": "Al-Qasim", + "nickname": "Son of the Rightly Guided", + "slug": "al-qasim-ibn-muhammad", + "origin": "Medina", + "lived_in": "Medina", + "died_in": "Medina", + "birth_year_hijri": 38, + "death_year_hijri": 106, + "age_at_death": 68, + "generation": 2, + "reliability": "reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Son of Amir al-Mu'minin, known for his knowledge of Islamic jurisprudence.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:32.785", + "updated_at": "2025-12-17T13:33:20.031" + } +}, +{ + "model": "hadis.transmitters", + "pk": 76, + "fields": { + "full_name": "Urwa ibn al-Zubayr", + "kunya": "Abu Abdullah", + "known_as": "Urwa", + "nickname": "The Jurist", + "slug": "urwa-ibn-al-zubayr", + "origin": "Medina", + "lived_in": "Medina, Mecca", + "died_in": "Medina", + "birth_year_hijri": 23, + "death_year_hijri": 94, + "age_at_death": 71, + "generation": 2, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Prominent Tabi'un scholar and transmitter of hadith from Aisha.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:33.184", + "updated_at": "2025-12-17T13:33:21.165" + } +}, +{ + "model": "hadis.transmitters", + "pk": 77, + "fields": { + "full_name": "Abdullah ibn Abbas", + "kunya": "Abu Abbas", + "known_as": "Ibn Abbas", + "nickname": "Hibr al-Ummah (The Learned Scholar of the Nation)", + "slug": "abdullah-ibn-abbas", + "origin": "Medina", + "lived_in": "Medina, Mecca, Basra", + "died_in": "Taif", + "birth_year_hijri": 3, + "death_year_hijri": 68, + "age_at_death": 71, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Cousin of the Prophet (PBUH), famous for Quranic exegesis and hadith knowledge.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:33.582", + "updated_at": "2025-12-17T13:33:19.464" + } +}, +{ + "model": "hadis.transmitters", + "pk": 78, + "fields": { + "full_name": "Nafi' (Mawla of Ibn Umar)", + "kunya": "Abu Abdullah", + "known_as": "Nafi'", + "nickname": "The Freed Slave", + "slug": "nafi-mawla-of-ibn-umar", + "origin": "Ethiopia", + "lived_in": "Medina", + "died_in": "Medina", + "birth_year_hijri": 25, + "death_year_hijri": 117, + "age_at_death": 92, + "generation": 2, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Freed slave of Abdullah ibn Umar, transmitted numerous hadith from him.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:33.979", + "updated_at": "2025-12-17T13:33:20.788" + } +}, +{ + "model": "hadis.transmitters", + "pk": 79, + "fields": { + "full_name": "Imam Ahmad ibn Hanbal", + "kunya": "Abu Abdullah", + "known_as": "Ahmad", + "nickname": "Shaykh al-Islam", + "slug": "imam-ahmad-ibn-hanbal", + "origin": "Khorasan", + "lived_in": "Baghdad", + "died_in": "Baghdad", + "birth_year_hijri": 164, + "death_year_hijri": 241, + "age_at_death": 77, + "generation": 4, + "reliability": "very_reliable", + "madhhab": "hanbali", + "in_sahih_muslim": false, + "in_sahih_bukhari": false, + "description": "Founder of the Hanbali school, compiler of Musnad Ahmad with 40,000+ hadith.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:34.376", + "updated_at": "2025-12-17T13:33:20.222" + } +}, +{ + "model": "hadis.transmitters", + "pk": 80, + "fields": { + "full_name": "Sufyan ibn Uyayna", + "kunya": "Abu Muhammad", + "known_as": "Sufyan", + "nickname": "Amir al-Mu'minin fil-Hadith", + "slug": "sufyan-ibn-uyayna", + "origin": "Kufa", + "lived_in": "Mecca, Kufa", + "died_in": "Mecca", + "birth_year_hijri": 107, + "death_year_hijri": 198, + "age_at_death": 91, + "generation": 3, + "reliability": "very_reliable", + "madhhab": "shafii", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": "Great hadith scholar and judge, known for his exceptional memory.", + "thumbnail": null, + "created_at": "2025-12-16T15:52:34.779", + "updated_at": "2025-12-17T13:33:20.976" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6285, + "fields": { + "hadis": 1800, + "transmitter": 71, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:42.368" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6286, + "fields": { + "hadis": 1800, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:42.770" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6287, + "fields": { + "hadis": 1801, + "transmitter": 72, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:43.168" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6288, + "fields": { + "hadis": 1801, + "transmitter": 76, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:43.568" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6289, + "fields": { + "hadis": 1801, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:43.969" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6290, + "fields": { + "hadis": 1802, + "transmitter": 73, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:44.368" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6291, + "fields": { + "hadis": 1802, + "transmitter": 75, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:44.769" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6292, + "fields": { + "hadis": 1802, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:45.168" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6293, + "fields": { + "hadis": 1803, + "transmitter": 77, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:45.566" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6294, + "fields": { + "hadis": 1803, + "transmitter": 78, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:45.966" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6295, + "fields": { + "hadis": 1803, + "transmitter": 79, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:46.366" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6296, + "fields": { + "hadis": 1804, + "transmitter": 71, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:46.770" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6297, + "fields": { + "hadis": 1804, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:47.176" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6298, + "fields": { + "hadis": 1805, + "transmitter": 72, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:47.577" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6299, + "fields": { + "hadis": 1805, + "transmitter": 75, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:47.977" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6300, + "fields": { + "hadis": 1806, + "transmitter": 73, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:48.377" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6301, + "fields": { + "hadis": 1806, + "transmitter": 78, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:48.774" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6302, + "fields": { + "hadis": 1806, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:49.173" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6303, + "fields": { + "hadis": 1806, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 4, + "is_gap": false, + "created_at": "2025-12-16T15:52:49.575" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6304, + "fields": { + "hadis": 1807, + "transmitter": 77, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:49.973" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6305, + "fields": { + "hadis": 1807, + "transmitter": 76, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:50.371" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6306, + "fields": { + "hadis": 1807, + "transmitter": 79, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:50.770" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6307, + "fields": { + "hadis": 1808, + "transmitter": 71, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:51.167" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6308, + "fields": { + "hadis": 1808, + "transmitter": 75, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:51.565" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6309, + "fields": { + "hadis": 1808, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:51.964" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6310, + "fields": { + "hadis": 1808, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 4, + "is_gap": false, + "created_at": "2025-12-16T15:52:52.358" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6311, + "fields": { + "hadis": 1809, + "transmitter": 72, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:52.758" + } +}, +{ + "model": "hadis.hadistransmitter", + "pk": 6312, + "fields": { + "hadis": 1809, + "transmitter": 78, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:53.158" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 15, + "fields": { + "transmitter": 71, + "scholar_name": "Imam al-Bukhari", + "opinion_text": "Abu Hurayrah is one of the most reliable narrators with a perfect memory and integrity.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:35.176", + "updated_at": "2025-12-16T15:52:35.176" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 16, + "fields": { + "transmitter": 71, + "scholar_name": "Imam Muslim", + "opinion_text": "His narrations are authentic and widely accepted in the Islamic jurisprudence.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:35.575", + "updated_at": "2025-12-16T15:52:35.575" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 17, + "fields": { + "transmitter": 72, + "scholar_name": "Imam al-Bukhari", + "opinion_text": "The Mother of the Believers is the most reliable source for hadith about the Prophet's household.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:35.972", + "updated_at": "2025-12-16T15:52:35.972" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 18, + "fields": { + "transmitter": 73, + "scholar_name": "Ibn Hajar al-Asqalani", + "opinion_text": "His longevity allowed him to transmit from many companions and successors.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:36.371", + "updated_at": "2025-12-16T15:52:36.371" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 19, + "fields": { + "transmitter": 74, + "scholar_name": "Imam ash-Shafi'i", + "opinion_text": "Malik is among the most knowledgeable of the Medinese scholars in jurisprudence.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:36.773", + "updated_at": "2025-12-16T15:52:36.773" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 20, + "fields": { + "transmitter": 74, + "scholar_name": "Imam Ahmad ibn Hanbal", + "opinion_text": "Malik's narrations form the basis of sound Islamic jurisprudence.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:37.171", + "updated_at": "2025-12-16T15:52:37.171" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 21, + "fields": { + "transmitter": 75, + "scholar_name": "Ibn Hajar al-Asqalani", + "opinion_text": "Al-Qasim is a trustworthy narrator from the Tabi'un generation with reliable traditions.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:37.570", + "updated_at": "2025-12-16T15:52:37.570" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 22, + "fields": { + "transmitter": 76, + "scholar_name": "Imam al-Bukhari", + "opinion_text": "Urwa is among the most knowledgeable about the traditions of the Prophet's household.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:37.969", + "updated_at": "2025-12-16T15:52:37.969" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 23, + "fields": { + "transmitter": 77, + "scholar_name": "Imam at-Tirmidhi", + "opinion_text": "Ibn Abbas has exceptional knowledge in Quranic interpretation and hadith narration.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:38.369", + "updated_at": "2025-12-16T15:52:38.369" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 24, + "fields": { + "transmitter": 78, + "scholar_name": "Imam Muslim", + "opinion_text": "Nafi' is one of the most reliable freed slaves who transmitted authentic traditions.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:38.767", + "updated_at": "2025-12-16T15:52:38.767" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 25, + "fields": { + "transmitter": 79, + "scholar_name": "Ibn Hajar al-Asqalani", + "opinion_text": "Ahmad ibn Hanbal's knowledge of hadith is unparalleled in his era.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:39.166", + "updated_at": "2025-12-16T15:52:39.166" + } +}, +{ + "model": "hadis.transmitteropinion", + "pk": 26, + "fields": { + "transmitter": 80, + "scholar_name": "Imam al-Bukhari", + "opinion_text": "Sufyan is a highly reliable hadith scholar with exceptional memory.", + "status": "confirmed", + "created_at": "2025-12-16T15:52:39.563", + "updated_at": "2025-12-16T15:52:39.563" + } +}, +{ + "model": "hadis.transmitteroriginaltext", + "pk": 9, + "fields": { + "transmitter": 71, + "title": "His Narration on Zakat", + "text": "حدثنا أبو هريرة قال: قال رسول الله صلى الله عليه وسلم: من آمن بالله واليوم الآخر فليؤد الزكاة", + "translation": [ + { + "text": "Abu Hurayrah narrated: The Messenger of Allah (PBUH) said: Whoever believes in Allah and the Last Day, let him pay the Zakat (alms).", + "language_code": "en" + }, + { + "text": "أبو هريرة: من آمن بالله واليوم الآخر فليؤد الزكاة", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/abu-hurayrah/zakat-1" + } +}, +{ + "model": "hadis.transmitteroriginaltext", + "pk": 10, + "fields": { + "transmitter": 72, + "title": "Her Account of the Prophet's Night Prayer", + "text": "قالت عائشة: كان النبي صلى الله عليه وسلم يقوم الليل فيصلي ثلاث عشرة ركعة", + "translation": [ + { + "text": "Aisha said: The Prophet (PBUH) used to pray at night thirteen rak'ahs.", + "language_code": "en" + }, + { + "text": "عائشة: كان النبي يقوم الليل بثلاث عشرة ركعة", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/aisha/night-prayer-1" + } +}, +{ + "model": "hadis.transmitteroriginaltext", + "pk": 11, + "fields": { + "transmitter": 73, + "title": "The Farewell Hajj Narration", + "text": "قال جابر: خرجنا مع رسول الله صلى الله عليه وسلم في حجة الوداع", + "translation": [ + { + "text": "Jabir narrated: We went out with the Messenger of Allah (PBUH) for the Farewell Hajj.", + "language_code": "en" + }, + { + "text": "جابر: خرجنا مع رسول الله في حجة الوداع", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/jabir/farewell-hajj-1" + } +}, +{ + "model": "hadis.transmitteroriginaltext", + "pk": 12, + "fields": { + "transmitter": 74, + "title": "Narration on Purity and Prayer", + "text": "قال مالك: الطهارة شرط من شروط الصلاة", + "translation": [ + { + "text": "Malik said: Purification is a condition for the validity of prayer.", + "language_code": "en" + }, + { + "text": "مالك: الطهارة من شروط صحة الصلاة", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/malik/purity-1" + } +}, +{ + "model": "hadis.transmitteroriginaltext", + "pk": 13, + "fields": { + "transmitter": 77, + "title": "His Commentary on Divine Justice", + "text": "قال ابن عباس: إن الله تعالى عدل لا يظلم أحدا", + "translation": [ + { + "text": "Ibn Abbas said: Truly Allah is Just and does not oppress anyone.", + "language_code": "en" + }, + { + "text": "ابن عباس: الله عدل لا يظلم أحدا", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/ibn-abbas/justice-1" + } +}, +{ + "model": "hadis.transmitteroriginaltext", + "pk": 14, + "fields": { + "transmitter": 80, + "title": "Teaching on Knowledge Seeking", + "text": "قال سفيان بن عيينة: طلب العلم فريضة على كل مسلم", + "translation": [ + { + "text": "Sufyan ibn Uyayna said: Seeking knowledge is an obligation for every Muslim.", + "language_code": "en" + }, + { + "text": "سفيان: طلب العلم فريضة على كل مسلم", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/sufyan/knowledge-1" + } +} +] diff --git a/apps/hadis/fixtures/transmitters_reformatted.json b/apps/hadis/fixtures/transmitters_reformatted.json new file mode 100644 index 0000000..ac84f04 --- /dev/null +++ b/apps/hadis/fixtures/transmitters_reformatted.json @@ -0,0 +1,1580 @@ +[ + { + "model": "hadis.narratorlayer", + "pk": 6, + "fields": { + "name": [ + { + "text": "Sahaba (Companions)", + "language_code": "en" + } + ], + "number": 1, + "description": [ + { + "text": "The companions of Prophet Muhammad (PBUH) who heard directly from him.", + "language_code": "en" + } + ], + "slug": "sahaba-companions", + "created_at": "2025-12-16T15:52:28.561", + "updated_at": "2025-12-16T15:52:28.561" + } + }, + { + "model": "hadis.narratorlayer", + "pk": 7, + "fields": { + "name": [ + { + "text": "Tabi'un (Successors)", + "language_code": "en" + } + ], + "number": 2, + "description": [ + { + "text": "Narrators who lived after the Prophet's era and heard from the companions.", + "language_code": "en" + } + ], + "slug": "tabiun-successors", + "created_at": "2025-12-16T15:52:28.959", + "updated_at": "2025-12-16T15:52:28.959" + } + }, + { + "model": "hadis.narratorlayer", + "pk": 8, + "fields": { + "name": [ + { + "text": "Taba' Tabi'in (Followers of Successors)", + "language_code": "en" + } + ], + "number": 3, + "description": [ + { + "text": "Narrators who heard from the Successors.", + "language_code": "en" + } + ], + "slug": "taba-tabiin-followers-of-successors", + "created_at": "2025-12-16T15:52:29.359", + "updated_at": "2025-12-16T15:52:29.359" + } + }, + { + "model": "hadis.narratorlayer", + "pk": 9, + "fields": { + "name": [ + { + "text": "Later Generations", + "language_code": "en" + } + ], + "number": 4, + "description": [ + { + "text": "Narrators from later Islamic centuries, known for their scholarship.", + "language_code": "en" + } + ], + "slug": "later-generations", + "created_at": "2025-12-16T15:52:29.759", + "updated_at": "2025-12-16T15:52:29.759" + } + }, + { + "model": "hadis.narratorlayer", + "pk": 10, + "fields": { + "name": [ + { + "text": "Modern Era Scholars", + "language_code": "en" + } + ], + "number": 5, + "description": [ + { + "text": "Contemporary scholars who have specialized knowledge of hadith narration.", + "language_code": "en" + } + ], + "slug": "modern-era-scholars", + "created_at": "2025-12-16T15:52:30.157", + "updated_at": "2025-12-16T15:52:30.157" + } + }, + { + "model": "hadis.transmitters", + "pk": 71, + "fields": { + "full_name": [ + { + "text": "Abu Hurayrah", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Hurayrah", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Abd al-Rahman ibn Sakhr", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Father of the Kitten", + "language_code": "en" + } + ], + "slug": "abu-hurayrah", + "origin": [ + { + "text": "Yamama, Arabia", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina, Syria", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": 7, + "death_year_hijri": 58, + "age_at_death": 78, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "One of the most prolific hadith narrators, reported 5,374 ahadith.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:30.856", + "updated_at": "2025-12-17T13:33:19.654" + } + }, + { + "model": "hadis.transmitters", + "pk": 72, + "fields": { + "full_name": [ + { + "text": "Aisha bint Abu Bakr", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Umm al-Mu'minin", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Mother of the Believers", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Al-Siddiqa", + "language_code": "en" + } + ], + "slug": "aisha-bint-abu-bakr", + "origin": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": null, + "death_year_hijri": 58, + "age_at_death": 66, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Wife of the Prophet Muhammad (PBUH), a major source of hadith about daily life.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:31.258", + "updated_at": "2025-12-17T13:33:19.843" + } + }, + { + "model": "hadis.transmitters", + "pk": 73, + "fields": { + "full_name": [ + { + "text": "Jabir ibn Abdullah al-Ansari", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Abdullah", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Jabir", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Al-Ansari", + "language_code": "en" + } + ], + "slug": "jabir-ibn-abdullah-al-ansari", + "origin": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": 10, + "death_year_hijri": 74, + "age_at_death": 94, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "One of the long-lived companions, reported numerous ahadith on various topics.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:31.987", + "updated_at": "2025-12-17T13:33:20.599" + } + }, + { + "model": "hadis.transmitters", + "pk": 74, + "fields": { + "full_name": [ + { + "text": "Imam Malik ibn Anas", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Abdullah", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Malik", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Imam of Imams", + "language_code": "en" + } + ], + "slug": "imam-malik-ibn-anas", + "origin": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": 93, + "death_year_hijri": 179, + "age_at_death": 86, + "generation": 3, + "reliability": "very_reliable", + "madhhab": "maliki", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Founder of the Maliki school of Islamic jurisprudence, compiler of Al-Muwatta.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:32.383", + "updated_at": "2025-12-17T13:33:20.412" + } + }, + { + "model": "hadis.transmitters", + "pk": 75, + "fields": { + "full_name": [ + { + "text": "Al-Qasim ibn Muhammad", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Muhammad", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Al-Qasim", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Son of the Rightly Guided", + "language_code": "en" + } + ], + "slug": "al-qasim-ibn-muhammad", + "origin": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": 38, + "death_year_hijri": 106, + "age_at_death": 68, + "generation": 2, + "reliability": "reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Son of Amir al-Mu'minin, known for his knowledge of Islamic jurisprudence.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:32.785", + "updated_at": "2025-12-17T13:33:20.031" + } + }, + { + "model": "hadis.transmitters", + "pk": 76, + "fields": { + "full_name": [ + { + "text": "Urwa ibn al-Zubayr", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Abdullah", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Urwa", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "The Jurist", + "language_code": "en" + } + ], + "slug": "urwa-ibn-al-zubayr", + "origin": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina, Mecca", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": 23, + "death_year_hijri": 94, + "age_at_death": 71, + "generation": 2, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Prominent Tabi'un scholar and transmitter of hadith from Aisha.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:33.184", + "updated_at": "2025-12-17T13:33:21.165" + } + }, + { + "model": "hadis.transmitters", + "pk": 77, + "fields": { + "full_name": [ + { + "text": "Abdullah ibn Abbas", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Abbas", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Ibn Abbas", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Hibr al-Ummah (The Learned Scholar of the Nation)", + "language_code": "en" + } + ], + "slug": "abdullah-ibn-abbas", + "origin": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina, Mecca, Basra", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Taif", + "language_code": "en" + } + ], + "birth_year_hijri": 3, + "death_year_hijri": 68, + "age_at_death": 71, + "generation": 1, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Cousin of the Prophet (PBUH), famous for Quranic exegesis and hadith knowledge.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:33.582", + "updated_at": "2025-12-17T13:33:19.464" + } + }, + { + "model": "hadis.transmitters", + "pk": 78, + "fields": { + "full_name": [ + { + "text": "Nafi' (Mawla of Ibn Umar)", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Abdullah", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Nafi'", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "The Freed Slave", + "language_code": "en" + } + ], + "slug": "nafi-mawla-of-ibn-umar", + "origin": [ + { + "text": "Ethiopia", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Medina", + "language_code": "en" + } + ], + "birth_year_hijri": 25, + "death_year_hijri": 117, + "age_at_death": 92, + "generation": 2, + "reliability": "very_reliable", + "madhhab": "sunni", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Freed slave of Abdullah ibn Umar, transmitted numerous hadith from him.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:33.979", + "updated_at": "2025-12-17T13:33:20.788" + } + }, + { + "model": "hadis.transmitters", + "pk": 79, + "fields": { + "full_name": [ + { + "text": "Imam Ahmad ibn Hanbal", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Abdullah", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Ahmad", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Shaykh al-Islam", + "language_code": "en" + } + ], + "slug": "imam-ahmad-ibn-hanbal", + "origin": [ + { + "text": "Khorasan", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Baghdad", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Baghdad", + "language_code": "en" + } + ], + "birth_year_hijri": 164, + "death_year_hijri": 241, + "age_at_death": 77, + "generation": 4, + "reliability": "very_reliable", + "madhhab": "hanbali", + "in_sahih_muslim": false, + "in_sahih_bukhari": false, + "description": [ + { + "text": "Founder of the Hanbali school, compiler of Musnad Ahmad with 40,000+ hadith.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:34.376", + "updated_at": "2025-12-17T13:33:20.222" + } + }, + { + "model": "hadis.transmitters", + "pk": 80, + "fields": { + "full_name": [ + { + "text": "Sufyan ibn Uyayna", + "language_code": "en" + } + ], + "kunya": [ + { + "text": "Abu Muhammad", + "language_code": "en" + } + ], + "known_as": [ + { + "text": "Sufyan", + "language_code": "en" + } + ], + "nickname": [ + { + "text": "Amir al-Mu'minin fil-Hadith", + "language_code": "en" + } + ], + "slug": "sufyan-ibn-uyayna", + "origin": [ + { + "text": "Kufa", + "language_code": "en" + } + ], + "lived_in": [ + { + "text": "Mecca, Kufa", + "language_code": "en" + } + ], + "died_in": [ + { + "text": "Mecca", + "language_code": "en" + } + ], + "birth_year_hijri": 107, + "death_year_hijri": 198, + "age_at_death": 91, + "generation": 3, + "reliability": "very_reliable", + "madhhab": "shafii", + "in_sahih_muslim": true, + "in_sahih_bukhari": true, + "description": [ + { + "text": "Great hadith scholar and judge, known for his exceptional memory.", + "language_code": "en" + } + ], + "thumbnail": null, + "created_at": "2025-12-16T15:52:34.779", + "updated_at": "2025-12-17T13:33:20.976" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6285, + "fields": { + "hadis": 1800, + "transmitter": 71, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:42.368" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6286, + "fields": { + "hadis": 1800, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:42.770" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6287, + "fields": { + "hadis": 1801, + "transmitter": 72, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:43.168" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6288, + "fields": { + "hadis": 1801, + "transmitter": 76, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:43.568" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6289, + "fields": { + "hadis": 1801, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:43.969" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6290, + "fields": { + "hadis": 1802, + "transmitter": 73, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:44.368" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6291, + "fields": { + "hadis": 1802, + "transmitter": 75, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:44.769" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6292, + "fields": { + "hadis": 1802, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:45.168" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6293, + "fields": { + "hadis": 1803, + "transmitter": 77, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:45.566" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6294, + "fields": { + "hadis": 1803, + "transmitter": 78, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:45.966" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6295, + "fields": { + "hadis": 1803, + "transmitter": 79, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:46.366" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6296, + "fields": { + "hadis": 1804, + "transmitter": 71, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:46.770" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6297, + "fields": { + "hadis": 1804, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:47.176" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6298, + "fields": { + "hadis": 1805, + "transmitter": 72, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:47.577" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6299, + "fields": { + "hadis": 1805, + "transmitter": 75, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:47.977" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6300, + "fields": { + "hadis": 1806, + "transmitter": 73, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:48.377" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6301, + "fields": { + "hadis": 1806, + "transmitter": 78, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:48.774" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6302, + "fields": { + "hadis": 1806, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:49.173" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6303, + "fields": { + "hadis": 1806, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 4, + "is_gap": false, + "created_at": "2025-12-16T15:52:49.575" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6304, + "fields": { + "hadis": 1807, + "transmitter": 77, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:49.973" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6305, + "fields": { + "hadis": 1807, + "transmitter": 76, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:50.371" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6306, + "fields": { + "hadis": 1807, + "transmitter": 79, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:50.770" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6307, + "fields": { + "hadis": 1808, + "transmitter": 71, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:51.167" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6308, + "fields": { + "hadis": 1808, + "transmitter": 75, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:51.565" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6309, + "fields": { + "hadis": 1808, + "transmitter": 74, + "narrator_layer": 8, + "status": "reliable", + "order": 3, + "is_gap": false, + "created_at": "2025-12-16T15:52:51.964" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6310, + "fields": { + "hadis": 1808, + "transmitter": 80, + "narrator_layer": 8, + "status": "reliable", + "order": 4, + "is_gap": false, + "created_at": "2025-12-16T15:52:52.358" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6311, + "fields": { + "hadis": 1809, + "transmitter": 72, + "narrator_layer": 6, + "status": "reliable", + "order": 1, + "is_gap": false, + "created_at": "2025-12-16T15:52:52.758" + } + }, + { + "model": "hadis.hadistransmitter", + "pk": 6312, + "fields": { + "hadis": 1809, + "transmitter": 78, + "narrator_layer": 7, + "status": "reliable", + "order": 2, + "is_gap": false, + "created_at": "2025-12-16T15:52:53.158" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 15, + "fields": { + "transmitter": 71, + "scholar_name": [ + { + "text": "Imam al-Bukhari", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Abu Hurayrah is one of the most reliable narrators with a perfect memory and integrity.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:35.176", + "updated_at": "2025-12-16T15:52:35.176" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 16, + "fields": { + "transmitter": 71, + "scholar_name": [ + { + "text": "Imam Muslim", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "His narrations are authentic and widely accepted in the Islamic jurisprudence.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:35.575", + "updated_at": "2025-12-16T15:52:35.575" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 17, + "fields": { + "transmitter": 72, + "scholar_name": [ + { + "text": "Imam al-Bukhari", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "The Mother of the Believers is the most reliable source for hadith about the Prophet's household.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:35.972", + "updated_at": "2025-12-16T15:52:35.972" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 18, + "fields": { + "transmitter": 73, + "scholar_name": [ + { + "text": "Ibn Hajar al-Asqalani", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "His longevity allowed him to transmit from many companions and successors.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:36.371", + "updated_at": "2025-12-16T15:52:36.371" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 19, + "fields": { + "transmitter": 74, + "scholar_name": [ + { + "text": "Imam ash-Shafi'i", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Malik is among the most knowledgeable of the Medinese scholars in jurisprudence.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:36.773", + "updated_at": "2025-12-16T15:52:36.773" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 20, + "fields": { + "transmitter": 74, + "scholar_name": [ + { + "text": "Imam Ahmad ibn Hanbal", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Malik's narrations form the basis of sound Islamic jurisprudence.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:37.171", + "updated_at": "2025-12-16T15:52:37.171" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 21, + "fields": { + "transmitter": 75, + "scholar_name": [ + { + "text": "Ibn Hajar al-Asqalani", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Al-Qasim is a trustworthy narrator from the Tabi'un generation with reliable traditions.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:37.570", + "updated_at": "2025-12-16T15:52:37.570" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 22, + "fields": { + "transmitter": 76, + "scholar_name": [ + { + "text": "Imam al-Bukhari", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Urwa is among the most knowledgeable about the traditions of the Prophet's household.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:37.969", + "updated_at": "2025-12-16T15:52:37.969" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 23, + "fields": { + "transmitter": 77, + "scholar_name": [ + { + "text": "Imam at-Tirmidhi", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Ibn Abbas has exceptional knowledge in Quranic interpretation and hadith narration.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:38.369", + "updated_at": "2025-12-16T15:52:38.369" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 24, + "fields": { + "transmitter": 78, + "scholar_name": [ + { + "text": "Imam Muslim", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Nafi' is one of the most reliable freed slaves who transmitted authentic traditions.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:38.767", + "updated_at": "2025-12-16T15:52:38.767" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 25, + "fields": { + "transmitter": 79, + "scholar_name": [ + { + "text": "Ibn Hajar al-Asqalani", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Ahmad ibn Hanbal's knowledge of hadith is unparalleled in his era.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:39.166", + "updated_at": "2025-12-16T15:52:39.166" + } + }, + { + "model": "hadis.transmitteropinion", + "pk": 26, + "fields": { + "transmitter": 80, + "scholar_name": [ + { + "text": "Imam al-Bukhari", + "language_code": "en" + } + ], + "opinion_text": [ + { + "text": "Sufyan is a highly reliable hadith scholar with exceptional memory.", + "language_code": "en" + } + ], + "status": "confirmed", + "created_at": "2025-12-16T15:52:39.563", + "updated_at": "2025-12-16T15:52:39.563" + } + }, + { + "model": "hadis.transmitteroriginaltext", + "pk": 9, + "fields": { + "transmitter": 71, + "title": [ + { + "text": "His Narration on Zakat", + "language_code": "en" + } + ], + "text": [ + { + "text": "حدثنا أبو هريرة قال: قال رسول الله صلى الله عليه وسلم: من آمن بالله واليوم الآخر فليؤد الزكاة", + "language_code": "fa" + } + ], + "translation": [ + { + "text": "Abu Hurayrah narrated: The Messenger of Allah (PBUH) said: Whoever believes in Allah and the Last Day, let him pay the Zakat (alms).", + "language_code": "en" + }, + { + "text": "أبو هريرة: من آمن بالله واليوم الآخر فليؤد الزكاة", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/abu-hurayrah/zakat-1" + } + }, + { + "model": "hadis.transmitteroriginaltext", + "pk": 10, + "fields": { + "transmitter": 72, + "title": [ + { + "text": "Her Account of the Prophet's Night Prayer", + "language_code": "en" + } + ], + "text": [ + { + "text": "قالت عائشة: كان النبي صلى الله عليه وسلم يقوم الليل فيصلي ثلاث عشرة ركعة", + "language_code": "fa" + } + ], + "translation": [ + { + "text": "Aisha said: The Prophet (PBUH) used to pray at night thirteen rak'ahs.", + "language_code": "en" + }, + { + "text": "عائشة: كان النبي يقوم الليل بثلاث عشرة ركعة", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/aisha/night-prayer-1" + } + }, + { + "model": "hadis.transmitteroriginaltext", + "pk": 11, + "fields": { + "transmitter": 73, + "title": [ + { + "text": "The Farewell Hajj Narration", + "language_code": "en" + } + ], + "text": [ + { + "text": "قال جابر: خرجنا مع رسول الله صلى الله عليه وسلم في حجة الوداع", + "language_code": "fa" + } + ], + "translation": [ + { + "text": "Jabir narrated: We went out with the Messenger of Allah (PBUH) for the Farewell Hajj.", + "language_code": "en" + }, + { + "text": "جابر: خرجنا مع رسول الله في حجة الوداع", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/jabir/farewell-hajj-1" + } + }, + { + "model": "hadis.transmitteroriginaltext", + "pk": 12, + "fields": { + "transmitter": 74, + "title": [ + { + "text": "Narration on Purity and Prayer", + "language_code": "en" + } + ], + "text": [ + { + "text": "قال مالك: الطهارة شرط من شروط الصلاة", + "language_code": "fa" + } + ], + "translation": [ + { + "text": "Malik said: Purification is a condition for the validity of prayer.", + "language_code": "en" + }, + { + "text": "مالك: الطهارة من شروط صحة الصلاة", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/malik/purity-1" + } + }, + { + "model": "hadis.transmitteroriginaltext", + "pk": 13, + "fields": { + "transmitter": 77, + "title": [ + { + "text": "His Commentary on Divine Justice", + "language_code": "en" + } + ], + "text": [ + { + "text": "قال ابن عباس: إن الله تعالى عدل لا يظلم أحدا", + "language_code": "fa" + } + ], + "translation": [ + { + "text": "Ibn Abbas said: Truly Allah is Just and does not oppress anyone.", + "language_code": "en" + }, + { + "text": "ابن عباس: الله عدل لا يظلم أحدا", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/ibn-abbas/justice-1" + } + }, + { + "model": "hadis.transmitteroriginaltext", + "pk": 14, + "fields": { + "transmitter": 80, + "title": [ + { + "text": "Teaching on Knowledge Seeking", + "language_code": "en" + } + ], + "text": [ + { + "text": "قال سفيان بن عيينة: طلب العلم فريضة على كل مسلم", + "language_code": "fa" + } + ], + "translation": [ + { + "text": "Sufyan ibn Uyayna said: Seeking knowledge is an obligation for every Muslim.", + "language_code": "en" + }, + { + "text": "سفيان: طلب العلم فريضة على كل مسلم", + "language_code": "ar" + } + ], + "share_link": "https://hadith.example.com/sufyan/knowledge-1" + } + } +] \ No newline at end of file diff --git a/apps/hadis/management/__init__.py b/apps/hadis/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/hadis/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/hadis/management/commands/README.md b/apps/hadis/management/commands/README.md new file mode 100644 index 0000000..d8f4a22 --- /dev/null +++ b/apps/hadis/management/commands/README.md @@ -0,0 +1,128 @@ +# Hadis Management Commands + +## seed_hadis_data + +<<<<<<< HEAD +This management command seeds comprehensive data for all Hadis app models with realistic sample records while maintaining proper relationships and business domain logic. +======= +This management command seeds comprehensive data for all Hadis app models with realistic sample records while maintaining proper relationships and business domain logic. **Enhanced with lock detection and retry logic to prevent database locks.** +>>>>>>> 932fb17 (Refactor API Documentation System and optimize Hadis data scripts) + +### Usage + +```bash +# Basic usage - seed data with default settings +python manage.py seed_hadis_data + +# Clear existing data before seeding +python manage.py seed_hadis_data --clear + +# Specify custom images directory +python manage.py seed_hadis_data --images-dir /path/to/images + +# Specify custom XMind file +python manage.py seed_hadis_data --xmind-file /path/to/file.xmind + +# Combine options +python manage.py seed_hadis_data --clear --images-dir scripts/seed_images --xmind-file scripts/test.xmind +``` + +### Options + +- `--clear`: Clear existing hadis data before seeding (optional) +- `--images-dir`: Directory containing seed images (default: scripts/seed_images) +- `--xmind-file`: Path to XMind file for categories (default: scripts/test.xmind) + +### What it creates + +1. **HadisStatus records**: Various hadis authenticity statuses (Достоверный, Хороший, etc.) +2. **HadisTag records**: Topic tags for categorizing hadis +3. **HadisSect records**: Shia and Sunni sects +4. **HadisCategory records**: Hierarchical categories for both Quran and Hadith sources +5. **Library data**: Books, categories, and collections for references +6. **Transmitters**: Historical figures who transmitted hadis +7. **Hadis records**: Complete hadis with translations, explanations, and relationships +8. **Transmission chains**: Links between hadis and transmitters +9. **References**: Book references with images + +### Requirements + +- The images directory must contain PNG files for book covers and reference images +- The XMind file is optional but recommended for category mind maps +- All models must be properly migrated before running + +<<<<<<< HEAD +### Performance + +The command uses optimized batch operations to create data efficiently: +- Bulk create/update operations for categories +- Checks for existing records to avoid duplicates +- Progress reporting for large datasets +======= +### Performance & Lock Prevention + +The command uses advanced techniques to prevent database locks and ensure reliable execution: +- **Lock Detection**: Automatically detects database locks and deadlocks +- **Retry Logic**: Retries failed operations with exponential backoff (up to 5 attempts) +- **Step-by-step Processing**: Creates records individually with small delays to prevent locks +- **Batch Processing**: Processes tags in small batches to avoid overwhelming the database +- **No Large Transactions**: Avoids wrapping everything in atomic transactions that can cause locks +- **Progress Reporting**: Detailed progress with emoji indicators and clear status messages +- **Error Handling**: Graceful handling of duplicate records and constraint violations +>>>>>>> 932fb17 (Refactor API Documentation System and optimize Hadis data scripts) + +### Example Output + +``` +Starting Hadis data seeding... +Found 4 seed images +XMind file: scripts/test.xmind +Creating Hadis Statuses... + Created status: Достоверный + Created status: Хороший +... +Creating Hadis Categories... + Creating categories for Шииты-двунадесятники... + Batch created 6 Quran categories +... +Successfully seeded all Hadis data! +``` +<<<<<<< HEAD +======= + +## test_safe_seeding + +A simple test command to verify that the lock detection and retry logic is working properly. + +### Usage + +```bash +# Test the safe seeding functionality +python manage.py test_safe_seeding +``` + +### What it tests + +- Database connectivity +- Lock detection mechanisms +- Retry logic for failed operations +- Creation of test records (sect, status, tag) + +## Additional Commands + +### fix_sects + +Fixes any issues with sect creation by using simple English titles. + +```bash +python manage.py fix_sects +``` + +### seed_basic_data + +Creates only the essential basic data (statuses, tags, sects) without the full dataset. + +```bash +python manage.py seed_basic_data [--clear] +``` +>>>>>>> 932fb17 (Refactor API Documentation System and optimize Hadis data scripts) diff --git a/apps/hadis/management/commands/__init__.py b/apps/hadis/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/hadis/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/hadis/management/commands/dump_with_encoding.py b/apps/hadis/management/commands/dump_with_encoding.py new file mode 100644 index 0000000..476b10f --- /dev/null +++ b/apps/hadis/management/commands/dump_with_encoding.py @@ -0,0 +1,54 @@ +# hadis/management/commands/dump_with_encoding.py +from django.core.management.base import BaseCommand +from django.core.management import call_command +import sys +import os + +class Command(BaseCommand): + help = 'Dump data with proper UTF-8 encoding' + + def add_arguments(self, parser): + parser.add_argument( + 'models', + nargs='+', + type=str, + help='Models to dump (e.g., hadis.HadisCategory hadis.HadisSect)' + ) + parser.add_argument( + '--output', + type=str, + default='fixture.json', + help='Output file path' + ) + parser.add_argument( + '--indent', + type=int, + default=2, + help='JSON indent level' + ) + + def handle(self, *args, **options): + models = options['models'] + output_file = options['output'] + indent = options['indent'] + + # Ensure directory exists + os.makedirs(os.path.dirname(output_file) or '.', exist_ok=True) + + self.stdout.write(f'Dumping models: {", ".join(models)}...') + + try: + # Force UTF-8 encoding + with open(output_file, 'w', encoding='utf-8') as f: + call_command( + 'dumpdata', + *models, + indent=indent, + stdout=f + ) + + self.stdout.write( + self.style.SUCCESS(f'✓ Successfully dumped to {output_file}') + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f'✗ Error: {str(e)}')) diff --git a/apps/hadis/management/commands/fix_sects.py b/apps/hadis/management/commands/fix_sects.py new file mode 100644 index 0000000..e5541c3 --- /dev/null +++ b/apps/hadis/management/commands/fix_sects.py @@ -0,0 +1,34 @@ +""" +Fix sects creation issue +""" + +from django.core.management.base import BaseCommand +from apps.hadis.models import HadisSect + + +class Command(BaseCommand): + help = 'Fix sects creation' + + def handle(self, **options): + self.stdout.write("Fixing sects...") + + # Delete any problematic sects + HadisSect.objects.filter(sect_type='sunni').delete() + + # Create sects with simple titles + sects_data = [ + {'sect_type': 'shia', 'title': 'Shia', 'is_active': True, 'order': 1}, + {'sect_type': 'sunni', 'title': 'Sunni', 'is_active': True, 'order': 2}, + ] + + for data in sects_data: + sect, created = HadisSect.objects.get_or_create( + sect_type=data['sect_type'], + defaults=data + ) + if created: + self.stdout.write(f"Created: {sect.sect_type} - {sect.title}") + else: + self.stdout.write(f"Exists: {sect.sect_type} - {sect.title}") + + self.stdout.write(self.style.SUCCESS("Sects fixed!")) diff --git a/apps/hadis/management/commands/generate_transmit_slug.py b/apps/hadis/management/commands/generate_transmit_slug.py new file mode 100644 index 0000000..dfbfbc5 --- /dev/null +++ b/apps/hadis/management/commands/generate_transmit_slug.py @@ -0,0 +1,71 @@ +# Create this file: yourapp/management/commands/generate_transmitter_slugs.py + +from django.core.management.base import BaseCommand +from django.utils.text import slugify +from apps.hadis.models import Transmitters # adjust import path as needed + + +class Command(BaseCommand): + help = 'Generate unique slugs for all transmitters' + + def add_arguments(self, parser): + parser.add_argument( + '--regenerate', + action='store_true', + help='Regenerate slugs even if they already exist', + ) + + def handle(self, *args, **options): + regenerate = options['regenerate'] + + transmitters = Transmitters.objects.all() + updated_count = 0 + skipped_count = 0 + + self.stdout.write( + self.style.SUCCESS(f'\n📝 Processing {transmitters.count()} transmitters...\n') + ) + + for transmitter in transmitters: + # Skip if slug exists and regenerate is False + if transmitter.slug and not regenerate: + self.stdout.write( + self.style.WARNING(f"⊘ Skipped: {transmitter.full_name} (slug exists)") + ) + skipped_count += 1 + continue + + # Generate base slug from full_name + base_slug = slugify(transmitter.full_name, allow_unicode=True) + + if not base_slug: + self.stdout.write( + self.style.ERROR(f"✗ Error: {transmitter.full_name} - Cannot generate slug from empty name") + ) + continue + + # Ensure uniqueness + slug = base_slug + counter = 1 + + while Transmitters.objects.filter(slug=slug).exclude(pk=transmitter.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + + # Update the transmitter + transmitter.slug = slug + transmitter.save() + updated_count += 1 + + self.stdout.write( + self.style.SUCCESS(f"✓ Generated: {transmitter.full_name} → {slug}") + ) + + # Print summary + self.stdout.write("\n" + "="*70) + self.stdout.write(self.style.SUCCESS("SLUG GENERATION SUMMARY")) + self.stdout.write("="*70) + self.stdout.write(f"✓ Generated: {updated_count}") + self.stdout.write(f"⊘ Skipped: {skipped_count}") + self.stdout.write(f"📊 Total: {transmitters.count()}") + self.stdout.write("="*70 + "\n") diff --git a/apps/hadis/management/commands/migrate_transmitter_reliability.py b/apps/hadis/management/commands/migrate_transmitter_reliability.py new file mode 100644 index 0000000..a54d2e5 --- /dev/null +++ b/apps/hadis/management/commands/migrate_transmitter_reliability.py @@ -0,0 +1,91 @@ +""" +Management command to migrate transmitter reliability data from CharField to ForeignKey. +Run this after the migration 0069_alter_transmitters_reliability has been applied. +""" + +from django.core.management.base import BaseCommand +from apps.hadis.models import Transmitters, TransmitterReliability + + +class Command(BaseCommand): + help = 'Migrate transmitter reliability data from CharField to ForeignKey' + + def handle(self, *args, **options): + self.stdout.write('Starting transmitter reliability data migration...') + + # Get or create reliability objects + reliability_mapping = self.get_reliability_mapping() + + # Update transmitters + updated_count = 0 + for transmitter in Transmitters.objects.all(): + old_value = getattr(transmitter, 'reliability_old', None) + if old_value and old_value in reliability_mapping: + transmitter.reliability = reliability_mapping[old_value] + transmitter.save() + updated_count += 1 + elif not transmitter.reliability: + # Set default for transmitters without reliability + transmitter.reliability = reliability_mapping['unknown'] + transmitter.save() + updated_count += 1 + + self.stdout.write( + self.style.SUCCESS( + f'Successfully migrated {updated_count} transmitters' + ) + ) + + def get_reliability_mapping(self): + """Get mapping of old values to new TransmitterReliability objects""" + mapping = {} + + # Define the reliability levels + reliability_data = [ + ('very_reliable', 'Very Reliable', 'green'), + ('reliable', 'Reliable', 'blue'), + ('acceptable', 'Acceptable', 'yellow'), + ('weak', 'Weak', 'orange'), + ('very_weak', 'Very Weak', 'red'), + ('unknown', 'Unknown', 'gray'), + ] + + for value, title, color in reliability_data: + obj, created = TransmitterReliability.objects.get_or_create( + title__0__text=title, # Check if object exists by title + defaults={ + 'title': [ + {'text': title, 'language_code': 'en'}, + {'text': self.get_persian_title(title), 'language_code': 'fa'}, + {'text': self.get_russian_title(title), 'language_code': 'ru'} + ], + 'color': color + } + ) + mapping[value] = obj + + return mapping + + def get_persian_title(self, english_title): + """Get Persian translation""" + translations = { + 'Very Reliable': 'بسیار قابل اعتماد', + 'Reliable': 'قابل اعتماد', + 'Acceptable': 'قابل قبول', + 'Weak': 'ضعیف', + 'Very Weak': 'بسیار ضعیف', + 'Unknown': 'نامشخص' + } + return translations.get(english_title, english_title) + + def get_russian_title(self, english_title): + """Get Russian translation""" + translations = { + 'Very Reliable': 'Очень надежный', + 'Reliable': 'Надежный', + 'Acceptable': 'Приемлемый', + 'Weak': 'Слабый', + 'Very Weak': 'Очень слабый', + 'Unknown': 'Неизвестный' + } + return translations.get(english_title, english_title) diff --git a/apps/hadis/management/commands/reformat_translations.py b/apps/hadis/management/commands/reformat_translations.py new file mode 100644 index 0000000..6ae6130 --- /dev/null +++ b/apps/hadis/management/commands/reformat_translations.py @@ -0,0 +1,64 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.hadis.models import HadisCorrection # adjust import if app/model name is different + + +def normalize_translation_items(value): + """ + Convert items like: + [{"lang": "en", "text": "..."}, {"lang": "fa", "text": "..."}] + to: + [{"language_code": "en", "text": "..."}, {"language_code": "fa", "text": "..."}] + + If already correct, leave as-is. Ignore items without text. + """ + if not value: + return [] + + if not isinstance(value, list): + # Unexpected format, just return as-is + return value + + cleaned = [] + for item in value: + if not isinstance(item, dict): + continue + + # Prefer existing language_code, else use lang + lang = item.get("language_code") or item.get("lang") + text = item.get("text") + + if not lang or not text: + continue + + cleaned.append( + { + "language_code": str(lang), + "text": text, + } + ) + + return cleaned + + +class Command(BaseCommand): + help = "Rename 'lang' to 'language_code' in HadisCorrection.translation JSON list entries." + + @transaction.atomic + def handle(self, *args, **options): + qs = HadisCorrection.objects.all() + total = qs.count() + self.stdout.write(f"Found {total} HadisCorrection objects") + + changed = 0 + for obj in qs: + old = obj.translation + new = normalize_translation_items(old) + + if new != old: + obj.translation = new + obj.save(update_fields=["translation"]) + changed += 1 + + self.stdout.write(self.style.SUCCESS(f"Updated {changed} HadisCorrection objects")) diff --git a/apps/hadis/management/commands/seed_basic_data.py b/apps/hadis/management/commands/seed_basic_data.py new file mode 100644 index 0000000..c1b6a72 --- /dev/null +++ b/apps/hadis/management/commands/seed_basic_data.py @@ -0,0 +1,138 @@ +""" +Basic data seeding management command for Hadis app models. +This command creates only the essential records needed for the app to function. +""" + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +# Import models +from apps.hadis.models import HadisSect, HadisStatus, HadisTag + + +class Command(BaseCommand): + help = 'Seed basic data for Hadis app models' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing basic data before seeding', + ) + + def handle(self, **options): + if options['clear']: + self.clear_existing_data() + + try: + with transaction.atomic(): + self.stdout.write( + self.style.SUCCESS('Starting basic Hadis data seeding...') + ) + + # Seed basic data + statuses = self.seed_hadis_statuses() + tags = self.seed_hadis_tags() + sects = self.seed_hadis_sects() + + self.stdout.write( + self.style.SUCCESS( + f'Successfully seeded basic data: ' + f'{len(statuses)} statuses, {len(tags)} tags, {len(sects)} sects' + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Error during seeding: {str(e)}') + ) + raise CommandError(f'Seeding failed: {str(e)}') + + def clear_existing_data(self): + """Clear existing basic data""" + self.stdout.write("Clearing existing basic data...") + + HadisSect.objects.all().delete() + HadisStatus.objects.all().delete() + HadisTag.objects.all().delete() + + self.stdout.write("Basic data cleared.") + + def seed_hadis_statuses(self): + """Create HadisStatus records""" + self.stdout.write("Creating Hadis Statuses...") + + statuses_data = [ + {'title': 'Достоверный', 'color': 'green', 'order': 1}, + {'title': 'Хороший', 'color': 'blue', 'order': 2}, + {'title': 'Слабый', 'color': 'yellow', 'order': 3}, + {'title': 'Выдуманный', 'color': 'red', 'order': 4}, + ] + + statuses = [] + for data in statuses_data: + status, created = HadisStatus.objects.get_or_create( + title=data['title'], + defaults=data + ) + statuses.append(status) + if created: + self.stdout.write(f" Created status: {status.title}") + + return statuses + + def seed_hadis_tags(self): + """Create HadisTag records""" + self.stdout.write("Creating Hadis Tags...") + + tags_data = [ + 'Поклонение', 'Молитва', 'Пост', 'Хадж', 'Закят', + 'Нравственность', 'Терпение', 'Справедливость', + 'Фикх', 'Предписания', 'Толкование', 'Коран', + 'Имамат', 'Мольба', 'Единобожие' + ] + + tags = [] + for tag_title in tags_data: + tag, created = HadisTag.objects.get_or_create( + title=tag_title, + defaults={'status': True} + ) + tags.append(tag) + if created: + self.stdout.write(f" Created tag: {tag.title}") + + return tags + + def seed_hadis_sects(self): + """Create HadisSect records""" + self.stdout.write("Creating Hadis Sects...") + + sects_data = [ + {'sect_type': 'shia', 'title': 'Шииты-двунадесятники', 'is_active': True, 'order': 1}, + {'sect_type': 'sunni', 'title': 'Сунниты', 'is_active': True, 'order': 2}, + ] + + sects = [] + for data in sects_data: + self.stdout.write(f" Processing sect: {data['sect_type']}") + + # Check if sect exists + try: + sect = HadisSect.objects.get(sect_type=data['sect_type']) + self.stdout.write(f" Sect already exists: {sect.title}") + sects.append(sect) + except HadisSect.DoesNotExist: + # Create new sect + self.stdout.write(f" Creating new sect: {data['sect_type']}") + sect = HadisSect( + sect_type=data['sect_type'], + title=data['title'], + is_active=data['is_active'], + order=data['order'] + ) + sect.save() + self.stdout.write(f" Created sect: {sect.title}") + sects.append(sect) + + return sects diff --git a/apps/hadis/management/commands/seed_books.py b/apps/hadis/management/commands/seed_books.py new file mode 100644 index 0000000..a321a3c --- /dev/null +++ b/apps/hadis/management/commands/seed_books.py @@ -0,0 +1,276 @@ +""" +Django management command to seed mock data for hadith book references. +Place this file in: yourapp/management/commands/seed_books.py + +Usage: python manage.py seed_books +""" + +import os +from pathlib import Path +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from django.utils.text import slugify +from apps.hadis.models.reference import ( + BookReference, + BookReferenceImage, + BookAuthor, + BookAttribute +) + + +class Command(BaseCommand): + help = 'Seed the database with mock hadith book reference data' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing data before seeding', + ) + + def handle(self, *args, **options): + if options['clear']: + self.stdout.write(self.style.WARNING('Clearing existing data...')) + BookAttribute.objects.all().delete() + BookReferenceImage.objects.all().delete() + BookAuthor.objects.all().delete() + BookReference.objects.all().delete() + self.stdout.write(self.style.SUCCESS('Data cleared successfully!')) + + # Create authors first + authors_data = [ + {"name": "Imam Muhammad al-Bukhari"}, + {"name": "Imam Muslim ibn al-Hajjaj"}, + {"name": "Imam Abu Dawood as-Sijistani"}, + {"name": "Imam At-Tirmidhi"}, + {"name": "Imam Ibn Majah"}, + {"name": "Imam Ahmad ibn Hanbal"}, + {"name": "Imam Al-Hakim"}, + {"name": "Imam Ad-Daraqutni"}, + ] + + authors = {} + for author_data in authors_data: + author, created = BookAuthor.objects.get_or_create( + name=author_data['name'] + ) + authors[author_data['name']] = author + if created: + self.stdout.write(self.style.SUCCESS(f'Created author: {author.name}')) + + # Create book references + books_data = [ + { + "title": "Sahih al-Bukhari", + "description": "The most authentic collection of hadith, compiled by Imam Muhammad al-Bukhari. Contains 7,563 ahadith.", + "language": "Arabic", + "isbn": "978-1-86043-009-6", + "volume": "9 volumes", + "year_of_publication": "1870", + "number_page": 1200, + "publisher": "Dar al-Kutub al-Ilmiyah", + "rate": 5.00, + "authors": ["Imam Muhammad al-Bukhari"], + "image_order": 1, + "attributes": { + "Collection Type": "Hadith Compilation", + "Number of Hadith": "7,563", + "Classification": "6 Books", + "Authenticity Grade": "Sahih (Authentic)", + "Compilation Period": "16 years", + } + }, + { + "title": "Sahih Muslim", + "description": "Second most authentic hadith collection compiled by Imam Muslim ibn al-Hajjaj. Contains 9,200 traditions.", + "language": "Arabic", + "isbn": "978-1-86043-010-2", + "volume": "5 volumes", + "year_of_publication": "1875", + "number_page": 1500, + "publisher": "Dar Ihya at-Turath al-Arabi", + "rate": 4.95, + "authors": ["Imam Muslim ibn al-Hajjaj"], + "image_order": 2, + "attributes": { + "Collection Type": "Hadith Compilation", + "Number of Hadith": "9,200", + "Classification": "43 Books", + "Authenticity Grade": "Sahih (Authentic)", + "Unique Hadith": "Approximately 4,000", + } + }, + { + "title": "Sunan Abu Dawood", + "description": "A comprehensive collection of hadith containing jurisprudential material, compiled by Imam Abu Dawood as-Sijistani.", + "language": "Arabic", + "isbn": "978-1-86043-011-9", + "volume": "4 volumes", + "year_of_publication": "1880", + "number_page": 1400, + "publisher": "Islamic Digital Library", + "rate": 4.80, + "authors": ["Imam Abu Dawood as-Sijistani"], + "image_order": 3, + "attributes": { + "Collection Type": "Sunan (Practice)", + "Number of Hadith": "5,274", + "Focus": "Jurisprudential Traditions", + "Number of Books": "43", + "Authenticity Grade": "Hasan to Sahih", + } + }, + { + "title": "Jami' at-Tirmidhi", + "description": "A major collection of hadith compiled by Imam At-Tirmidhi with his commentary and grading of narrations.", + "language": "Arabic", + "isbn": "978-1-86043-012-6", + "volume": "5 volumes", + "year_of_publication": "1892", + "number_page": 1350, + "publisher": "Dar ar-Risalah al-Alamiyah", + "rate": 4.85, + "authors": ["Imam At-Tirmidhi"], + "image_order": 4, + "attributes": { + "Collection Type": "Jami (Comprehensive)", + "Number of Hadith": "3,956", + "Notable Feature": "Grades each hadith", + "Categories": "63 Chapters", + "Authenticity Grade": "Various Grades", + } + }, + { + "title": "Sunan Ibn Majah", + "description": "A collection of hadith compiled by Imam Ibn Majah, one of the Six Canonical Hadith Collections.", + "language": "Arabic", + "isbn": "978-1-86043-013-3", + "volume": "2 volumes", + "year_of_publication": "1888", + "number_page": 900, + "publisher": "Dar Ihya al-Kutub al-Arabiyah", + "rate": 4.75, + "authors": ["Imam Ibn Majah"], + "image_order": 5, + "attributes": { + "Collection Type": "Sunan (Practice)", + "Number of Hadith": "4,341", + "Number of Books": "32", + "Notable Content": "Includes rare narrations", + "Authenticity Grade": "Mixed - requires verification", + } + }, + ] + + books = {} + for book_data in books_data: + # Extract author names + author_names = book_data.pop('authors', []) + image_order = book_data.pop('image_order', 1) + attributes_dict = book_data.pop('attributes', {}) + + # Create or get the book + book, created = BookReference.objects.get_or_create( + title=book_data['title'], + defaults=book_data + ) + + if created: + self.stdout.write(self.style.SUCCESS(f'Created book: {book.title}')) + else: + # Update existing book + for key, value in book_data.items(): + setattr(book, key, value) + book.save() + self.stdout.write(self.style.WARNING(f'Updated book: {book.title}')) + + books[book.title] = book + + # Add authors to book + for author_name in author_names: + author = authors.get(author_name) + if author: + book.authors.add(author) + + # Add book image + image_path = self._get_image_path(image_order) + if image_path and os.path.exists(image_path): + # Check if image already exists for this book + if not book.images.exists(): + with open(image_path, 'rb') as img_file: + image_name = f'book{image_order}.png' + book_image = BookReferenceImage.objects.create( + book_reference=book, + order=1, + description=f"Cover image for {book.title}" + ) + book_image.image.save( + image_name, + ContentFile(img_file.read()), + save=True + ) + self.stdout.write( + self.style.SUCCESS(f'Added image to: {book.title}') + ) + else: + self.stdout.write( + self.style.WARNING( + f'Image not found at {image_path} for {book.title}' + ) + ) + + # Add attributes + for attr_title, attr_value in attributes_dict.items(): + attribute, created = BookAttribute.objects.get_or_create( + book_reference=book, + title=attr_title, + defaults={'value': attr_value} + ) + if created: + self.stdout.write( + self.style.SUCCESS( + f'Added attribute: {attr_title} to {book.title}' + ) + ) + + self.stdout.write( + self.style.SUCCESS( + f'\n✓ Successfully seeded {len(books)} books with all relations!' + ) + ) + self._print_summary() + + def _get_image_path(self, book_number): + """ + Find the image file for the given book number. + Checks multiple possible locations. + """ + possible_paths = [ + Path('seeds/images') / f'book{book_number}.png', + Path('seed_data/images') / f'book{book_number}.png', + Path('static/images') / f'book{book_number}.png', + Path('.') / 'seeds' / 'images' / f'book{book_number}.png', + ] + + for path in possible_paths: + if path.exists(): + return path + + return None + + def _print_summary(self): + """Print a summary of created data""" + self.stdout.write("\n" + "="*60) + self.stdout.write(self.style.SUCCESS("DATABASE SUMMARY")) + self.stdout.write("="*60) + + books_count = BookReference.objects.count() + authors_count = BookAuthor.objects.count() + images_count = BookReferenceImage.objects.count() + attributes_count = BookAttribute.objects.count() + + self.stdout.write(f"📚 Total Books: {books_count}") + self.stdout.write(f"✍️ Total Authors: {authors_count}") + self.stdout.write(f"🖼️ Total Images: {images_count}") + self.stdout.write(f"🏷️ Total Attributes: {attributes_count}") + self.stdout.write("="*60 + "\n") \ No newline at end of file diff --git a/apps/hadis/management/commands/seed_category_data.py b/apps/hadis/management/commands/seed_category_data.py new file mode 100644 index 0000000..19dab69 --- /dev/null +++ b/apps/hadis/management/commands/seed_category_data.py @@ -0,0 +1,497 @@ +""" +Django management command to seed mock HadisCategory trees and new Hadis +for history, fatwa and quote sources. + +Place this file in: hadis/management/commands/seed_categories.py + +Usage: + python manage.py seed_categories + python manage.py seed_categories --clear +""" + +from django.core.management.base import BaseCommand +from django.utils.text import slugify +from django.conf import settings + +from apps.hadis.models.category import HadisCategory, HadisSect +from apps.hadis.models.hadis import Hadis, HadisStatus, HadisTag + + +class Command(BaseCommand): + help = "Seed HadisCategory trees (history, fatwa, quote) and new Hadis connected to them." + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear ONLY categories & hadises created by this command (history/fatwa/quote).', + ) + + def handle(self, *args, **options): + if options['clear']: + self._clear_seeded_data() + + # Ensure sects exist + try: + shia_sect = HadisSect.objects.get(sect_type=HadisSect.SectType.SHIA) + except HadisSect.DoesNotExist: + shia_sect = HadisSect.objects.create( + sect_type=HadisSect.SectType.SHIA, + title="Shia", + description="Default Shia sect" + ) + + try: + sunni_sect = HadisSect.objects.get(sect_type=HadisSect.SectType.SUNNI) + except HadisSect.DoesNotExist: + sunni_sect = HadisSect.objects.create( + sect_type=HadisSect.SectType.SUNNI, + title="Sunni", + description="Default Sunni sect" + ) + + # Optional: ensure a couple of HadisStatus entries exist + default_status, _ = HadisStatus.objects.get_or_create( + title="Authentic / Accepted", + defaults={"color": HadisStatus.ColorChoices.GREEN, "order": 1}, + ) + weak_status, _ = HadisStatus.objects.get_or_create( + title="Weak / Needs Review", + defaults={"color": HadisStatus.ColorChoices.YELLOW, "order": 2}, + ) + + # Ensure some tags exist + tags = self._ensure_tags() + + # 1) Seed HISTORY categories & hadises + self.stdout.write(self.style.SUCCESS("\n📚 Creating HISTORY categories and hadiths...")) + self._seed_history_tree(sunni_sect, default_status, tags) + + # 2) Seed FATWA categories & hadises + self.stdout.write(self.style.SUCCESS("\n📜 Creating FATWA categories and hadiths...")) + self._seed_fatwa_tree(sunni_sect, default_status, weak_status, tags) + + # 3) Seed QUOTE categories & hadises + self.stdout.write(self.style.SUCCESS("\n💬 Creating QUOTE categories and hadiths...")) + self._seed_quote_tree(shia_sect, default_status, tags) + + self._print_summary() + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + + def _clear_seeded_data(self): + """ + Clear only categories with source_type in (history, fatwa, quote) + and hadises that are attached to those categories. + """ + self.stdout.write(self.style.WARNING("Clearing previously seeded history/fatwa/quote data...")) + leaf_categories = HadisCategory.objects.filter( + source_type__in=[ + HadisCategory.SourceType.HISTORY, + HadisCategory.SourceType.FATWA, + HadisCategory.SourceType.QUOTE, + ] + ) + Hadis.objects.filter(category__in=leaf_categories).delete() + # Delete categories (MPTT will handle tree structure) + HadisCategory.objects.filter( + source_type__in=[ + HadisCategory.SourceType.HISTORY, + HadisCategory.SourceType.FATWA, + HadisCategory.SourceType.QUOTE, + ] + ).delete() + self.stdout.write(self.style.SUCCESS("✓ Cleared seeded history/fatwa/quote categories and hadiths.")) + + def _ensure_tags(self): + base_titles = [ + "history", + "biography", + "battle", + "ethics", + "jurisprudence", + "family", + "wisdom", + "short quote", + ] + tags = [] + for title in base_titles: + tag, _ = HadisTag.objects.get_or_create(title=title) + tags.append(tag) + return tags + + # ---------------- HISTORY ---------------- # + + def _seed_history_tree(self, sect, default_status, tags): + """ + Create a small HISTORY tree: + History of Islam + ├─ Early Caliphate (leaf) + └─ Battles (leaf) + And add several hadith-like historical reports. + """ + root, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.HISTORY, + title="History of Islam", + defaults={ + "description": "High-level historical themes related to early Islamic history.", + "order": 1, + }, + ) + + early_caliphate, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.HISTORY, + parent=root, + title="Early Caliphate", + defaults={ + "description": "Events and reports from the period of the first caliphs.", + "order": 1, + }, + ) + + battles, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.HISTORY, + parent=root, + title="Battles and Expeditions", + defaults={ + "description": "Key battles and expeditions in early Islamic history.", + "order": 2, + }, + ) + + # Create some historical “hadith” entries (reports) + # For simplicity, use new numbers starting from 1900+ + self._create_hadis( + number=1900, + category=early_caliphate, + title="The Consultation of the Companions", + text=( + "It is reported in historical works that the companions would gather and " + "consult one another regarding major affairs of the community." + ), + translation_en=( + "Historical reports mention that the companions would consult one another " + "on major communal matters." + ), + status=default_status, + tags=[t for t in tags if t.title in ["history", "biography"]], + ) + + self._create_hadis( + number=1901, + category=early_caliphate, + title="Establishment of the Public Treasury", + text=( + "Some historians narrate that during the early caliphate a public treasury " + "was organized to administer charity and public funds." + ), + translation_en=( + "Historical sources state that a public treasury was set up to manage " + "charity and communal funds." + ), + status=default_status, + tags=[t for t in tags if t.title in ["history", "jurisprudence"]], + ) + + self._create_hadis( + number=1910, + category=battles, + title="Report of the Battle Preparations", + text=( + "Chronicles record that the believers prepared carefully before major battles, " + "ensuring justice and discipline in their ranks." + ), + translation_en=( + "Historical chronicles record careful preparations and discipline before major battles." + ), + status=default_status, + tags=[t for t in tags if t.title in ["history", "battle"]], + ) + + self._create_hadis( + number=1911, + category=battles, + title="Mercy Shown After Victory", + text=( + "Historical narrations mention that after some victories, clemency and mercy were " + "shown to prisoners and civilians." + ), + translation_en=( + "Historical narrations mention mercy and clemency after some military victories." + ), + status=default_status, + tags=[t for t in tags if t.title in ["history", "ethics"]], + ) + + # ---------------- FATWA ---------------- # + + def _seed_fatwa_tree(self, sect, default_status, weak_status, tags): + """ + Create a FATWA tree like: + Contemporary Fatwas + ├─ Worship (leaf) + └─ Family Issues (leaf) + Plus a second root: + Financial Fatwas + └─ Trade and Contracts (leaf) + """ + root_contemporary, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.FATWA, + title="Contemporary Fatwas", + defaults={ + "description": "Modern juristic responses to contemporary questions.", + "order": 1, + }, + ) + + worship, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.FATWA, + parent=root_contemporary, + title="Worship and Rituals", + defaults={ + "description": "Fatwas about prayer, fasting and other acts of worship.", + "order": 1, + }, + ) + + family_issues, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.FATWA, + parent=root_contemporary, + title="Family Issues", + defaults={ + "description": "Fatwas regarding marriage, divorce and family obligations.", + "order": 2, + }, + ) + + financial_root, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.FATWA, + title="Financial Fatwas", + defaults={ + "description": "Juristic rulings about trade, contracts and modern finance.", + "order": 2, + }, + ) + + trade_contracts, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.FATWA, + parent=financial_root, + title="Trade and Contracts", + defaults={ + "description": "Fatwas related to buying, selling and contractual agreements.", + "order": 1, + }, + ) + + # Create some “fatwa-style” hadith entries (answers / rulings) + self._create_hadis( + number=1920, + category=worship, + title="Fatwa on Combining Prayers While Traveling", + text=( + "A contemporary juristic council has ruled that combining prayers while traveling " + "is permitted when hardship is present, following classical precedents." + ), + translation_en=( + "A modern fatwa allows combining prayers during travel where hardship exists, " + "based on earlier jurisprudence." + ), + status=default_status, + tags=[t for t in tags if t.title in ["jurisprudence", "ethics"]], + ) + + self._create_hadis( + number=1921, + category=worship, + title="Fatwa on Using Local Calculations for Prayer Times", + text=( + "Modern scholars have issued fatwas permitting the use of accurate astronomical " + "calculations for determining prayer times." + ), + translation_en=( + "Contemporary fatwas permit using precise astronomical calculations for prayer times." + ), + status=weak_status, + tags=[t for t in tags if t.title in ["jurisprudence", "knowledge"]], + ) + + self._create_hadis( + number=1930, + category=family_issues, + title="Fatwa on Upholding Family Ties", + text=( + "A fatwa committee emphasized that maintaining family ties is obligatory and that " + "cutting off relatives without valid reason is sinful." + ), + translation_en=( + "A fatwa stresses that keeping family ties is obligatory and severing them without cause is sinful." + ), + status=default_status, + tags=[t for t in tags if t.title in ["family", "ethics"]], + ) + + self._create_hadis( + number=1940, + category=trade_contracts, + title="Fatwa on Transparent Business Contracts", + text=( + "Scholars have ruled that contracts must be transparent and free from deception in order " + "to be valid in Islamic law." + ), + translation_en=( + "A modern fatwa requires transparency and absence of deception in business contracts." + ), + status=default_status, + tags=[t for t in tags if t.title in ["business", "jurisprudence"]] if any( + t.title == "business" for t in tags + ) else [t for t in tags if t.title in ["jurisprudence"]], + ) + + # ---------------- QUOTE ---------------- # + + def _seed_quote_tree(self, sect, default_status, tags): + """ + Create a QUOTE tree: + Wisdom Quotes + ├─ Short Wisdom (leaf) + └─ On Knowledge (leaf) + """ + root, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.QUOTE, + title="Wisdom Quotes", + defaults={ + "description": "Short wise sayings and inspirational quotes.", + "order": 1, + }, + ) + + short_wisdom, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.QUOTE, + parent=root, + title="Short Wisdom", + defaults={ + "description": "Very short, memorable quotes on character and behavior.", + "order": 1, + }, + ) + + on_knowledge, _ = HadisCategory.objects.get_or_create( + sect=sect, + source_type=HadisCategory.SourceType.QUOTE, + parent=root, + title="On Knowledge", + defaults={ + "description": "Quotes emphasizing the virtue of knowledge and learning.", + "order": 2, + }, + ) + + # Short quote-style entries + self._create_hadis( + number=1950, + category=short_wisdom, + title="The Measure of a Heart", + text="It is said: The worth of a person is in what their heart carries of mercy and truth.", + translation_en="It is said: A person’s value is measured by the mercy and truth in their heart.", + status=default_status, + tags=[t for t in tags if t.title in ["wisdom", "short quote", "ethics"]], + ) + + self._create_hadis( + number=1951, + category=short_wisdom, + title="Silence and Wisdom", + text="One of the wise said: Many people would be considered wise if they knew when to remain silent.", + translation_en="A wise saying: Many would be counted wise if they knew when to be silent.", + status=default_status, + tags=[t for t in tags if t.title in ["wisdom", "short quote"]], + ) + + self._create_hadis( + number=1960, + category=on_knowledge, + title="Seeking Knowledge as Light", + text="It is narrated from the scholars: Knowledge is a light that guides the heart towards what benefits it.", + translation_en="Scholars say: Knowledge is a light guiding the heart to what benefits it.", + status=default_status, + tags=[t for t in tags if t.title in ["knowledge", "wisdom"]], + ) + + self._create_hadis( + number=1961, + category=on_knowledge, + title="Learning until the End", + text="One sage said: Continue to seek knowledge until the last day of your life, for ignorance is a darkness.", + translation_en="A sage said: Seek knowledge until your last day, for ignorance is darkness.", + status=default_status, + tags=[t for t in tags if t.title in ["knowledge", "wisdom"]], + ) + + # ---------------- utility to create Hadis ---------------- # + + def _create_hadis(self, number, category, title, text, translation_en, status, tags): + """ + Create or update a Hadis with the given number and category. + Translation uses {'en': '...'} structure as requested. + """ + hadis, created = Hadis.objects.get_or_create( + number=number, + category=category, + defaults={ + "title": title, + "title_narrator": None, + "description": "", + "text": text, + "translation": {"en": translation_en}, + "status": True, + "hadis_status": status, + "hadis_status_text": status.title if status else "", + "address": "", + "links": {}, + "explanation": "", + }, + ) + if not created: + hadis.title = title + hadis.text = text + hadis.translation = {"en": translation_en} + hadis.hadis_status = status + hadis.hadis_status_text = status.title if status else "" + hadis.category = category + hadis.save() + + if tags: + hadis.tags.set(tags) + return hadis + + def _print_summary(self): + from apps.hadis.models import HadisCategory, Hadis + + self.stdout.write("\n" + "=" * 70) + self.stdout.write(self.style.SUCCESS("CATEGORY & HADIS SUMMARY")) + self.stdout.write("=" * 70) + + history_count = HadisCategory.objects.filter(source_type=HadisCategory.SourceType.HISTORY).count() + fatwa_count = HadisCategory.objects.filter(source_type=HadisCategory.SourceType.FATWA).count() + quote_count = HadisCategory.objects.filter(source_type=HadisCategory.SourceType.QUOTE).count() + + history_hadis = Hadis.objects.filter(category__source_type=HadisCategory.SourceType.HISTORY).count() + fatwa_hadis = Hadis.objects.filter(category__source_type=HadisCategory.SourceType.FATWA).count() + quote_hadis = Hadis.objects.filter(category__source_type=HadisCategory.SourceType.QUOTE).count() + + self.stdout.write(f"📂 HISTORY categories: {history_count} | hadith-like records: {history_hadis}") + self.stdout.write(f"📂 FATWA categories: {fatwa_count} | fatwa records: {fatwa_hadis}") + self.stdout.write(f"📂 QUOTE categories: {quote_count} | quote records: {quote_hadis}") + self.stdout.write("=" * 70 + "\n") diff --git a/apps/hadis/management/commands/seed_hadis_2.py b/apps/hadis/management/commands/seed_hadis_2.py new file mode 100644 index 0000000..0f1f8b9 --- /dev/null +++ b/apps/hadis/management/commands/seed_hadis_2.py @@ -0,0 +1,128 @@ +import random +from django.core.management.base import BaseCommand +from django.core.exceptions import ObjectDoesNotExist +# Ensure these imports match your actual app structure +from apps.hadis.models import ( + Hadis, + HadisCollection, + HadisInCollection, + HadisCorrection +) + +class Command(BaseCommand): + help = 'Seeds Hadis Collections and Corrections for Hadis IDs 1877-1888' + + def create_json_field(self, en_text, fa_text, ru_text): + """Helper to create your specific JSON format with Russian added""" + return [ + {"text": en_text, "language_code": "en"}, + {"text": fa_text, "language_code": "fa"}, + {"text": ru_text, "language_code": "ru"} + ] + + def handle(self, *args, **options): + self.stdout.write("--- Starting Hadis Extras Seeding ---") + + # --------------------------------------------------------- + # 1. Create Hadis Collections + # --------------------------------------------------------- + self.stdout.write("Creating Hadis Collections...") + + collections_data = [ + { + "title": ("The Book of Intellect and Ignorance", "کتاب عقل و جهل", "Книга разума и невежества"), + "summary": ( + "A collection of narrations regarding the importance of reason.", + "مجموعه‌ای از روایات درباره اهمیت عقل و خرد.", + "Сборник преданий о важности разума." + ) + }, + { + "title": ("The Book of Monotheism", "کتاب التوحید", "Книга Единобожия"), + "summary": ( + "Hadiths explaining the oneness of God.", + "احادیثی که یگانگی خداوند را توضیح می‌دهند.", + "Хадисы, объясняющие единство Бога." + ) + } + ] + + created_collections = [] + for c_data in collections_data: + # We use the English title as a loose identifier to prevent duplicates + slug_base = c_data["title"][0].lower().replace(" ", "-") + + # Simple check if exists to avoid duplication in multiple runs + if HadisCollection.objects.filter(slug__startswith=slug_base).exists(): + collection = HadisCollection.objects.filter(slug__startswith=slug_base).first() + self.stdout.write(f"Collection '{c_data['title'][0]}' already exists.") + else: + collection = HadisCollection.objects.create( + title=self.create_json_field(*c_data["title"]), + summary=self.create_json_field(*c_data["summary"]), + status=True + ) + self.stdout.write(self.style.SUCCESS(f"Created Collection: {c_data['title'][0]}")) + + created_collections.append(collection) + + # --------------------------------------------------------- + # 2. Process Hadis (1877 - 1888) + # --------------------------------------------------------- + self.stdout.write("Processing Hadis IDs 1877-1888...") + + target_ids = range(1877, 1889) + + for hadis_id in target_ids: + try: + hadis_obj = Hadis.objects.get(id=hadis_id) + except ObjectDoesNotExist: + self.stdout.write(self.style.WARNING(f"Hadis ID {hadis_id} not found. Skipping.")) + continue + + # --- A. Add to Collection --- + # Clear previous collection links for this hadis to avoid clutter + HadisInCollection.objects.filter(hadis=hadis_obj).delete() + + # Assign to a random collection from our list + target_collection = random.choice(created_collections) + + HadisInCollection.objects.create( + hadis=hadis_obj, + collection=target_collection, + order=hadis_id # Just using ID as order for simplicity + ) + self.stdout.write(f" -> Linked Hadis {hadis_id} to '{target_collection}'") + + # --- B. Create Correction --- + # Mocking a correction entry + # We'll assume these are translation corrections or scholarly notes + + correction_title = ( + f"Correction for Hadis {hadis_id}", + f"اصلاحیه برای حدیث {hadis_id}", + f"Исправление для хадиса {hadis_id}" + ) + + correction_desc = ( + "The word 'Wali' here implies authority, not just friendship.", + "کلمه «ولی» در اینجا دلالت بر ولایت دارد، نه صرفاً دوستی.", + "Слово «Вали» здесь означает власть, а не просто дружбу." + ) + + correction_trans = ( + "Verily, I am the authority over the believers...", + "همانا من ولی و سرپرست مؤمنان هستم...", + "Воистину, я — покровитель верующих..." + ) + + HadisCorrection.objects.create( + hadis=hadis_obj, + title=self.create_json_field(*correction_title), + description=self.create_json_field(*correction_desc), + translation=self.create_json_field(*correction_trans), + share_link=f"https://example.com/corrections/{hadis_id}" + ) + self.stdout.write(f" -> Created Correction for Hadis {hadis_id}") + + self.stdout.write(self.style.SUCCESS("--- Hadis Extras Seeding Complete ---")) \ No newline at end of file diff --git a/apps/hadis/management/commands/seed_hadis_data.py b/apps/hadis/management/commands/seed_hadis_data.py new file mode 100644 index 0000000..1e0cd54 --- /dev/null +++ b/apps/hadis/management/commands/seed_hadis_data.py @@ -0,0 +1,934 @@ +""" +Comprehensive data seeding management command for Hadis app models. +This command creates realistic sample records for all Hadis app models +while maintaining proper relationships and business domain logic. +""" + +import random +import time +from pathlib import Path +from django.core.management.base import BaseCommand, CommandError +from django.core.files import File +from django.core.files.base import ContentFile +from django.db import connection +from django.db.utils import OperationalError, IntegrityError + +# Import models +from apps.hadis.models import ( + HadisSect, HadisCategory, HadisStatus, HadisTag, Hadis, + Transmitters, HadisTransmitter, HadisReference, ReferenceImage +) +from apps.library.models import Book, Category as LibraryCategory, BookCollection + + +class Command(BaseCommand): + help = 'Seed comprehensive data for Hadis app models' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing hadis data before seeding', + ) + parser.add_argument( + '--images-dir', + type=str, + default='scripts/seed_images', + help='Directory containing seed images (default: scripts/seed_images)', + ) + parser.add_argument( + '--xmind-file', + type=str, + default='scripts/test.xmind', + help='Path to XMind file (default: scripts/test.xmind)', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.seed_images_dir = None + self.xmind_file_path = None + self.image_files = [] + self.retry_delay = 2 # seconds + self.max_retries = 5 + + def handle(self, **options): + self.setup_paths(options) + + # Check database status before starting + self.stdout.write("🔍 Checking database status...") + self.check_database_locks() + + if options['clear']: + self.stdout.write("🧹 Clearing existing data...") + self.safe_execute_with_retry("Clear existing data", self.clear_existing_data) + + try: + self.stdout.write( + self.style.SUCCESS('🚀 Starting Hadis data seeding...') + ) + + # Seed data in proper order WITHOUT transaction.atomic() to avoid locks + self.stdout.write("📊 Step 1: Creating basic lookup data...") + statuses = self.safe_execute_with_retry("Seed statuses", self.seed_hadis_statuses) + + self.stdout.write("🏷️ Step 2: Creating tags...") + tags = self.safe_execute_with_retry("Seed tags", self.seed_hadis_tags) + + self.stdout.write("🕌 Step 3: Creating sects...") + sects = self.safe_execute_with_retry("Seed sects", self.seed_hadis_sects) + + self.stdout.write("📚 Step 4: Creating categories...") + categories = self.safe_execute_with_retry("Seed categories", self.seed_hadis_categories, sects) + + self.stdout.write("📖 Step 5: Creating library data...") + books, _, _ = self.safe_execute_with_retry("Seed library data", self.seed_library_data) + + self.stdout.write("👥 Step 6: Creating transmitters...") + transmitters = self.safe_execute_with_retry("Seed transmitters", self.seed_transmitters) + + self.stdout.write("📜 Step 7: Creating hadis records...") + self.safe_execute_with_retry("Seed hadis records", self.seed_hadis_records, + categories, statuses, tags, transmitters, books) + + self.stdout.write( + self.style.SUCCESS('✅ Successfully seeded all Hadis data!') + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f'❌ Error during seeding: {str(e)}') + ) + import traceback + self.stdout.write(self.style.ERROR(traceback.format_exc())) + raise CommandError(f'Seeding failed: {str(e)}') + + def setup_paths(self, options): + """Setup file paths and verify required files exist""" + self.seed_images_dir = Path(options['images_dir']) + self.xmind_file_path = Path(options['xmind_file']) + + # Verify required files exist + if not self.seed_images_dir.exists(): + raise CommandError(f"Seed images directory not found: {self.seed_images_dir}") + + # Get available images + self.image_files = list(self.seed_images_dir.glob('*.png')) + if not self.image_files: + raise CommandError("No PNG images found in seed_images directory") + + self.stdout.write(f"Found {len(self.image_files)} seed images") + if self.xmind_file_path.exists(): + self.stdout.write(f"XMind file: {self.xmind_file_path}") + else: + self.stdout.write(self.style.WARNING(f"XMind file not found: {self.xmind_file_path}")) + + def clear_existing_data(self): + """Clear existing hadis data (optional - for clean seeding)""" + self.stdout.write("Clearing existing hadis data...") + + # Clear in reverse dependency order + ReferenceImage.objects.all().delete() + HadisReference.objects.all().delete() + HadisTransmitter.objects.all().delete() + Hadis.objects.all().delete() + HadisCategory.objects.all().delete() + HadisSect.objects.all().delete() + HadisStatus.objects.all().delete() + HadisTag.objects.all().delete() + Transmitters.objects.all().delete() + + self.stdout.write("Existing data cleared.") + + def safe_execute_with_retry(self, operation_name, operation_func, *args, **kwargs): + """Execute database operation with retry logic for handling locks""" + for attempt in range(self.max_retries): + try: + self.stdout.write(f" Attempting {operation_name} (attempt {attempt + 1}/{self.max_retries})") + result = operation_func(*args, **kwargs) + self.stdout.write(f" ✓ {operation_name} completed successfully") + return result + + except OperationalError as e: + error_msg = str(e).lower() + if 'database is locked' in error_msg or 'deadlock' in error_msg: + self.stdout.write( + self.style.WARNING( + f" ⚠ Database lock detected in {operation_name}: {str(e)}" + ) + ) + if attempt < self.max_retries - 1: + self.stdout.write(f" ⏳ Waiting {self.retry_delay} seconds before retry...") + time.sleep(self.retry_delay) + # Increase delay for next attempt + self.retry_delay = min(self.retry_delay * 1.5, 10) + else: + self.stdout.write( + self.style.ERROR(f" ❌ Max retries reached for {operation_name}") + ) + raise + else: + # Non-lock related error, don't retry + self.stdout.write( + self.style.ERROR(f" ❌ Non-lock error in {operation_name}: {str(e)}") + ) + raise + + except IntegrityError as e: + # Handle unique constraint violations gracefully + if 'unique' in str(e).lower() or 'duplicate' in str(e).lower(): + self.stdout.write( + self.style.WARNING(f" ⚠ Record already exists in {operation_name}: {str(e)}") + ) + return None # Indicate that record already exists + else: + self.stdout.write( + self.style.ERROR(f" ❌ Integrity error in {operation_name}: {str(e)}") + ) + raise + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Unexpected error in {operation_name}: {str(e)}") + ) + raise + + def check_database_locks(self): + """Check for existing database locks""" + try: + with connection.cursor() as cursor: + # Check for SQLite locks (if using SQLite) + cursor.execute("PRAGMA locking_mode;") + locking_mode = cursor.fetchone() + self.stdout.write(f"Database locking mode: {locking_mode}") + + # Force a simple query to test connectivity + cursor.execute("SELECT 1;") + cursor.fetchone() + + except Exception as e: + self.stdout.write( + self.style.WARNING(f"Could not check database locks: {str(e)}") + ) + + def create_single_status(self, status_data): + """Create a single status with proper error handling""" + status, created = HadisStatus.objects.get_or_create( + title=status_data['title'], + defaults=status_data + ) + if created: + self.stdout.write(f" ✅ Created status: {status.title}") + else: + self.stdout.write(f" ✓ Status already exists: {status.title}") + return status + + def seed_hadis_statuses(self): + """Create HadisStatus records""" + self.stdout.write("Creating Hadis Statuses...") + + statuses_data = [ + {'title': 'Достоверный', 'color': 'green', 'order': 1}, + {'title': 'Хороший', 'color': 'blue', 'order': 2}, + {'title': 'Слабый', 'color': 'yellow', 'order': 3}, + {'title': 'Выдуманный', 'color': 'red', 'order': 4}, + {'title': 'Прерванный', 'color': 'orange', 'order': 5}, + {'title': 'Разорванный', 'color': 'purple', 'order': 6}, + {'title': 'Неизвестный', 'color': 'gray', 'order': 7}, + ] + + statuses = [] + for i, data in enumerate(statuses_data): + self.stdout.write(f" 📋 Processing status {i+1}/{len(statuses_data)}: {data['title']}") + + # Add small delay between operations + if i > 0: + time.sleep(0.2) + + status = self.safe_execute_with_retry( + f"Create status {data['title']}", + self.create_single_status, + data + ) + + if status: + statuses.append(status) + + self.stdout.write(f"✅ Successfully processed {len(statuses)} statuses") + return statuses + + def create_single_tag(self, tag_title): + """Create a single tag with proper error handling""" + tag, created = HadisTag.objects.get_or_create( + title=tag_title, + defaults={'status': True} + ) + if created: + self.stdout.write(f" ✅ Created tag: {tag.title}") + else: + self.stdout.write(f" ✓ Tag already exists: {tag.title}") + return tag + + def seed_hadis_tags(self): + """Create HadisTag records""" + self.stdout.write("Creating Hadis Tags...") + + tags_data = [ + 'Поклонение', 'Молитва', 'Пост', 'Хадж', 'Закят', 'Хумс', + 'Нравственность', 'Терпение', 'Благодарность', 'Доверие', 'Богобоязненность', 'Справедливость', + 'Фикх', 'Постановления', 'Халяль', 'Харам', 'Мустахаб', 'Макрух', + 'Толкование', 'Коран', 'Аяты', 'Сура', 'Чтение', + 'Имамат', 'Власть', 'Непорочные', 'Семья Пророка', + 'Мольба', 'Поминание', 'Прощение', 'Восхваление', 'Единобожие' + ] + + tags = [] + # Process tags in smaller batches to avoid locks + batch_size = 5 + for i in range(0, len(tags_data), batch_size): + batch = tags_data[i:i + batch_size] + self.stdout.write(f" 📋 Processing tag batch {i//batch_size + 1}/{(len(tags_data) + batch_size - 1)//batch_size}") + + for j, tag_title in enumerate(batch): + # Add small delay between operations + if j > 0: + time.sleep(0.1) + + tag = self.safe_execute_with_retry( + f"Create tag {tag_title}", + self.create_single_tag, + tag_title + ) + + if tag: + tags.append(tag) + + # Delay between batches + if i + batch_size < len(tags_data): + time.sleep(0.5) + + self.stdout.write(f"✅ Successfully processed {len(tags)} tags") + return tags + + def create_single_sect(self, sect_data): + """Create a single sect with proper error handling""" + sect_type = sect_data['sect_type'] + + # Check if sect already exists + try: + existing_sect = HadisSect.objects.get(sect_type=sect_type) + self.stdout.write(f" ✓ Sect '{sect_type}' already exists: {existing_sect.title}") + return existing_sect + except HadisSect.DoesNotExist: + pass + + # Create new sect + self.stdout.write(f" 🔨 Creating new sect: {sect_type}") + sect = HadisSect( + sect_type=sect_data['sect_type'], + title=sect_data['title'], + is_active=sect_data['is_active'], + order=sect_data['order'] + ) + sect.save() + self.stdout.write(f" ✅ Created sect: {sect.title}") + return sect + + def seed_hadis_sects(self): + """Create HadisSect records""" + self.stdout.write("Creating Hadis Sects...") + + sects_data = [ + {'sect_type': 'shia', 'title': 'Шииты-двунадесятники', 'is_active': True, 'order': 1}, + {'sect_type': 'sunni', 'title': 'Сунниты', 'is_active': True, 'order': 2}, + ] + + sects = [] + + # Process each sect individually with delay + for i, data in enumerate(sects_data): + sect_type = data['sect_type'] + self.stdout.write(f" 📋 Processing sect {i+1}/{len(sects_data)}: {sect_type}") + + # Add small delay between operations to prevent locks + if i > 0: + time.sleep(0.5) + + sect = self.safe_execute_with_retry( + f"Create sect {sect_type}", + self.create_single_sect, + data + ) + + if sect: + sects.append(sect) + + self.stdout.write(f"✅ Successfully processed {len(sects)} sects") + return sects + + def assign_xmind_file(self, category): + """Assign XMind file to category""" + if not self.xmind_file_path.exists(): + return False + + try: + with open(self.xmind_file_path, 'rb') as f: + file_content = f.read() + + # Create unique filename for each category + filename = f"category_{category.id}_{category.title[:20]}.xmind" + category.xmind_file.save( + filename, + ContentFile(file_content), + save=True + ) + return True + except Exception as e: + self.stdout.write( + self.style.WARNING(f"Could not assign XMind file to {category.title}: {e}") + ) + return False + + def create_single_category(self, sect, source_type, title, order, parent=None): + """Create a single category with proper MPTT handling""" + try: + # Check if category already exists + existing_category = HadisCategory.objects.get( + sect=sect, + source_type=source_type, + title=title, + parent=parent + ) + self.stdout.write(f" ✓ Category already exists: {title}") + return existing_category + except HadisCategory.DoesNotExist: + pass + + # Create new category (MPTT will handle tree fields automatically) + self.stdout.write(f" 🔨 Creating category: {title}") + category = HadisCategory.objects.create( + sect=sect, + source_type=source_type, + title=title, + order=order, + parent=parent + ) + self.stdout.write(f" ✅ Created category: {title}") + return category + + def seed_hadis_categories(self, sects): + """Create HadisCategory records with hierarchical structure - MPTT safe creation""" + self.stdout.write("Creating Hadis Categories...") + + categories = [] + + for sect in sects: + self.stdout.write(f" 📋 Creating categories for {sect.title}...") + + # Quran categories - create one by one to avoid MPTT issues + quran_categories_data = [ + {'title': 'Толкование Корана', 'order': 1}, + {'title': 'Аяты постановлений', 'order': 2}, + {'title': 'Коранические истории', 'order': 3}, + {'title': 'Достоинства сур', 'order': 4}, + {'title': 'Чудеса Корана', 'order': 5}, + {'title': 'Коранические науки', 'order': 6}, + ] + + # Create main Quran categories one by one + quran_parent_categories = [] + for i, cat_data in enumerate(quran_categories_data): + # Add delay between operations + if i > 0: + time.sleep(0.3) + + category = self.safe_execute_with_retry( + f"Create Quran category {cat_data['title']}", + self.create_single_category, + sect, 'quran', cat_data['title'], cat_data['order'] + ) + + if category: + categories.append(category) + quran_parent_categories.append(category) + + # Create child categories for Quran + self.stdout.write(" 📂 Creating Quran child categories...") + for parent_category in quran_parent_categories: + child_categories_data = [] + + if parent_category.title == 'Толкование Корана': + child_categories_data = [ + {'title': 'Толкование суры Аль-Фатиха', 'order': 1}, + {'title': 'Толкование суры Аль-Бакара', 'order': 2}, + {'title': 'Толкование суры Аль Имран', 'order': 3}, + ] + elif parent_category.title == 'Аяты постановлений': + child_categories_data = [ + {'title': 'Аяты о молитве', 'order': 1}, + {'title': 'Аяты о посте', 'order': 2}, + {'title': 'Аяты о закяте', 'order': 3}, + ] + elif parent_category.title == 'Коранические истории': + child_categories_data = [ + {'title': 'Истории пророков', 'order': 1}, + {'title': 'Истории праведников', 'order': 2}, + ] + + # Create child categories one by one + for j, child_data in enumerate(child_categories_data): + # Add delay between operations + if j > 0: + time.sleep(0.2) + + child_category = self.safe_execute_with_retry( + f"Create child category {child_data['title']}", + self.create_single_category, + sect, 'quran', child_data['title'], child_data['order'], parent_category + ) + + if child_category: + categories.append(child_category) + + # Assign XMind file to some categories + if random.choice([True, False]) and not parent_category.xmind_file: + self.assign_xmind_file(parent_category) + + # Hadith categories - create one by one + self.stdout.write(" 📚 Creating Hadith categories...") + hadith_categories_data = [ + {'title': 'Книга очищения', 'order': 1}, + {'title': 'Книга молитвы', 'order': 2}, + {'title': 'Книга поста', 'order': 3}, + {'title': 'Книга хаджа', 'order': 4}, + {'title': 'Книга закята', 'order': 5}, + {'title': 'Книга нравственности', 'order': 6}, + ] + + # Create main Hadith categories one by one + hadith_parent_categories = [] + for i, cat_data in enumerate(hadith_categories_data): + # Add delay between operations + if i > 0: + time.sleep(0.3) + + category = self.safe_execute_with_retry( + f"Create Hadith category {cat_data['title']}", + self.create_single_category, + sect, 'hadith', cat_data['title'], cat_data['order'] + ) + + if category: + categories.append(category) + hadith_parent_categories.append(category) + + # Create child categories for Hadith + self.stdout.write(" 📂 Creating Hadith child categories...") + for parent_category in hadith_parent_categories: + child_categories_data = [] + + if parent_category.title == 'Книга очищения': + child_categories_data = [ + {'title': 'Омовение', 'order': 1}, + {'title': 'Полное омовение', 'order': 2}, + {'title': 'Сухое омовение', 'order': 3}, + ] + elif parent_category.title == 'Книга молитвы': + child_categories_data = [ + {'title': 'Времена молитв', 'order': 1}, + {'title': 'Направление киблы', 'order': 2}, + {'title': 'Коллективная молитва', 'order': 3}, + ] + elif parent_category.title == 'Книга нравственности': + child_categories_data = [ + {'title': 'Терпение и благодарность', 'order': 1}, + {'title': 'Справедливость и честность', 'order': 2}, + ] + + # Create child categories one by one + for j, child_data in enumerate(child_categories_data): + # Add delay between operations + if j > 0: + time.sleep(0.2) + + child_category = self.safe_execute_with_retry( + f"Create child category {child_data['title']}", + self.create_single_category, + sect, 'hadith', child_data['title'], child_data['order'], parent_category + ) + + if child_category: + categories.append(child_category) + + # Assign XMind file to some categories + if random.choice([True, False]) and not parent_category.xmind_file: + self.assign_xmind_file(parent_category) + + self.stdout.write(f"✅ Successfully processed {len(categories)} categories") + return categories + + def seed_library_data(self): + """Create library data (books, categories, collections) for references""" + self.stdout.write("Creating Library data...") + + # Create library categories + lib_categories_data = [ + 'Книги хадисов', 'Книги фикха', 'Книги толкования', 'Книги нравственности', 'Исторические книги' + ] + + lib_categories = [] + for cat_title in lib_categories_data: + category, created = LibraryCategory.objects.get_or_create( + title=cat_title, + defaults={'status': True} + ) + lib_categories.append(category) + if created: + self.stdout.write(f" Created library category: {category.title}") + + # Create book collections + collections_data = [ + {'title': 'Шиитские книги хадисов', 'display_position': 'pinned'}, + {'title': 'Суннитские книги хадисов', 'display_position': 'middle'}, + {'title': 'Сборник книг по фикху', 'display_position': 'middle'}, + ] + + collections = [] + for coll_data in collections_data: + collection, created = BookCollection.objects.get_or_create( + title=coll_data['title'], + defaults={ + 'summary': f'Коллекция {coll_data["title"]}', + 'display_position': coll_data['display_position'], + 'status': True, + 'order': len(collections) + 1 + } + ) + collections.append(collection) + if created: + self.stdout.write(f" Created collection: {collection.title}") + + # Create books with cover images + books_data = [ + { + 'title': 'Аль-Кафи', + 'summary_title': 'Книга Аль-Кафи шейха Кулейни', + 'summary': 'Одна из важнейших книг хадисов шиитов', + 'description': 'Книга Аль-Кафи, написанная Мухаммадом ибн Якубом Кулейни, является одной из четырех достоверных книг хадисов шиитов.', + 'publisher': 'Дар аль-Кутуб аль-Исламийя', + 'year_of_publication': '1407', + 'isbn': '978-964-372-001-1', + 'pages_count': '2847', + 'file_type': 'pdf' + }, + { + 'title': 'Сахих аль-Бухари', + 'summary_title': 'Сахих аль-Бухари имама Бухари', + 'summary': 'Самая достоверная книга хадисов суннитов', + 'description': 'Сахих аль-Бухари, написанный Мухаммадом ибн Исмаилом Бухари, является самой достоверной книгой хадисов у суннитов.', + 'publisher': 'Дар Тук ан-Наджа', + 'year_of_publication': '1422', + 'isbn': '978-964-372-002-2', + 'pages_count': '1896', + 'file_type': 'pdf' + }, + { + 'title': 'Ман ля яхдуруху аль-факих', + 'summary_title': 'Ман ля яхдуруху аль-факих шейха Садука', + 'summary': 'Важная книга по фикху и хадисам шиитов', + 'description': 'Книга Ман ля яхдуруху аль-факих, написанная шейхом Садуком, является одной из четырех книг шиитов.', + 'publisher': 'Муассаса ан-Нашр аль-Ислами', + 'year_of_publication': '1413', + 'isbn': '978-964-372-003-3', + 'pages_count': '1524', + 'file_type': 'pdf' + }, + { + 'title': 'Сунан Абу Дауд', + 'summary_title': 'Сунан Абу Дауд имама Абу Дауда', + 'summary': 'Одна из шести книг суннитов', + 'description': 'Сунан Абу Дауд, написанная Сулейманом ибн Ашасом Сиджистани, является одной из шести книг суннитов.', + 'publisher': 'Аль-Мактаба аль-Асрийя', + 'year_of_publication': '1430', + 'isbn': '978-964-372-004-4', + 'pages_count': '1342', + 'file_type': 'pdf' + }, + ] + + books = [] + for book_data in books_data: + # Get random image for book cover + image_file = random.choice(self.image_files) + + book, created = Book.objects.get_or_create( + title=book_data['title'], + defaults=book_data + ) + + if created: + # Assign cover image + try: + with open(image_file, 'rb') as f: + book.thumbnail.save( + f"book_cover_{book.id}.png", + File(f), + save=True + ) + self.stdout.write(f" Created book: {book.title} with cover image") + except Exception as e: + self.stdout.write(f" Created book: {book.title} (no cover image: {e})") + + # Assign to categories and collections + if lib_categories: + book.categories.add(random.choice(lib_categories)) + if collections: + book.collections.add(random.choice(collections)) + + books.append(book) + + return books, lib_categories, collections + + def seed_transmitters(self): + """Create Transmitters records""" + self.stdout.write("Creating Transmitters...") + + transmitters_data = [ + { + 'full_name': 'Мухаммад ибн Якуб Кулейни', + 'birth_year_hijri': 250, + 'death_year_hijri': 329, + 'description': 'Шейх Кулейни, автор книги Аль-Кафи и один из великих мухаддисов шиитов' + }, + { + 'full_name': 'Мухаммад ибн Али ибн Бабавейх (Шейх Садук)', + 'birth_year_hijri': 306, + 'death_year_hijri': 381, + 'description': 'Шейх Садук, автор книги Ман ля яхдуруху аль-факих' + }, + { + 'full_name': 'Мухаммад ибн аль-Хасан ат-Туси', + 'birth_year_hijri': 385, + 'death_year_hijri': 460, + 'description': 'Шейх Туси, автор книг Тахзиб аль-Ахкам и аль-Истибсар' + }, + { + 'full_name': 'Мухаммад ибн Исмаил аль-Бухари', + 'birth_year_hijri': 194, + 'death_year_hijri': 256, + 'description': 'Имам Бухари, автор Сахих аль-Бухари' + }, + { + 'full_name': 'Муслим ибн аль-Хаджжадж ан-Нишапури', + 'birth_year_hijri': 206, + 'death_year_hijri': 261, + 'description': 'Имам Муслим, автор Сахих Муслим' + }, + { + 'full_name': 'Абу Дауд ас-Сиджистани', + 'birth_year_hijri': 202, + 'death_year_hijri': 275, + 'description': 'Имам Абу Дауд, автор Сунан Абу Дауд' + }, + { + 'full_name': 'Джафар ибн Мухаммад ас-Садик', + 'birth_year_hijri': 83, + 'death_year_hijri': 148, + 'description': 'Имам Джафар Садик (мир ему), шестой имам шиитов' + }, + { + 'full_name': 'Мухаммад ибн Али аль-Бакир', + 'birth_year_hijri': 57, + 'death_year_hijri': 114, + 'description': 'Имам Мухаммад Бакир (мир ему), пятый имам шиитов' + }, + { + 'full_name': 'Али ибн аль-Хусейн ас-Саджжад', + 'birth_year_hijri': 38, + 'death_year_hijri': 95, + 'description': 'Имам Али ибн аль-Хусейн (мир ему), четвертый имам шиитов' + }, + { + 'full_name': 'Мухаммад ибн Муслим', + 'birth_year_hijri': 70, + 'death_year_hijri': 150, + 'description': 'Мухаммад ибн Муслим, из сподвижников имама Бакира и имама Садика (мир им)' + }, + ] + + transmitters = [] + for trans_data in transmitters_data: + transmitter, created = Transmitters.objects.get_or_create( + full_name=trans_data['full_name'], + defaults=trans_data + ) + transmitters.append(transmitter) + if created: + self.stdout.write(f" Created transmitter: {transmitter.full_name}") + + return transmitters + + def seed_hadis_records(self, categories, statuses, tags, transmitters, books): + """Create Hadis records with proper relationships - optimized batch creation""" + self.stdout.write("Creating Hadis records...") + + # Get only leaf categories (categories without children) - optimized query + from django.db import models + leaf_categories = HadisCategory.objects.filter( + id__in=[cat.id for cat in categories] + ).annotate( + children_count=models.Count('children') + ).filter(children_count=0) + + self.stdout.write(f"Found {len(leaf_categories)} leaf categories for hadis creation") + + # Comprehensive hadis samples with longer texts + hadis_samples = { + 'prayer': [ + { + 'title': 'Достоинство молитвы и ее место в религии', + 'text': '''قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله. + +والصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام. + +إن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.''', + 'translation': [ + {'language_code': 'ru', 'title': '''Сказал Посланник Аллаха (да благословит Аллах его и его семейство): Молитва - столп религии, если она принята, то принято и остальное, а если отвергнута, то отвергнуто и остальное. Это первое, за что будет спрошен раб в День Воскресения, и если она будет правильной, то правильными будут и остальные его дела, а если испорчена, то испорчены и остальные его дела. + +Молитва - это вознесение верующего, она - приношение каждого богобоязненного, она - любовь Всевышнего Аллаха. Кто полюбил ее и совершал ее в установленные времена и соблюдал ее границы, того Аллах возвысит до степени праведников. А кто пренебрег ею, потерял ее и оставил, тот пренебрег религией Аллаха, и нет ему доли в Исламе. + +Поистине, Всевышний Аллах предписал пять молитв в сутки и установил для каждой молитвы определенное время. Кто совершал их в свое время и завершал их поклоны, земные поклоны и смирение, для того они станут светом, доказательством и спасением в День Воскресения.'''}, + {'language_code': 'fa', 'title': '''رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود غیر آن نیز پذیرفته می‌شود و اگر رد شود غیر آن نیز رد می‌شود. و این اولین چیزی است که بنده در روز قیامت از آن بازخواست می‌شود، پس اگر درست باشد تمام اعمالش درست است و اگر فاسد باشد تمام اعمالش فاسد است. + +نماز معراج مؤمن است و قربانی هر پرهیزکار و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش برپا دارد و حدودش را نگه دارد، خداوند او را به درجه نیکان بالا می‌برد. و هر کس آن را سبک بشمارد و ضایع کند و ترک کند، دین خدا را سبک شمرده و بهره‌ای در اسلام ندارد. + +خداوند متعال پنج نماز در شبانه‌روز واجب کرده و برای هر نماز وقت معینی قرار داده، پس هر کس آن‌ها را در وقتشان بخواند و رکوع و سجود و خشوعشان را کامل کند، برایش نور و برهان و نجات در روز قیامت خواهد بود.'''}, + {'language_code': 'en', 'title': '''The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted. + +Prayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah's religion lightly, and has no share in Islam. + +Indeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.'''} + ], + 'explanation': '''Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов: + +Во-первых, молитва описывается как "столп религии" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы. + +Во-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение. + +В-третьих, молитва представлена как "معراج المؤمن" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему. + +Хадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.''' + }, + ], + 'fasting': [ + { + 'title': 'Достоинство поста и его духовные плоды', + 'text': '''قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً. + +إن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه. + +يا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.''', + 'translation': [ + {'language_code': 'ru', 'title': '''Сказал Пророк (да благословит Аллах его и его семейство): Пост - щит от огня, и он закят тела, и пост месяца терпения и трех дней каждого месяца устраняют жар груди и наущения шайтана. И кто постился день на пути Аллаха, Аллах отдалит его лицо от огня на семьдесят осеней. + +Поистине, постящийся находится в поклонении, даже если он спит на своей постели, и его мольба принимается до тех пор, пока он не разговеется, и ангелы просят прощения для него до тех пор, пока он не разговеется. И у постящегося две радости: радость при разговении и радость при встрече со своим Господом. + +О молодежь! Кто из вас способен жениться, пусть женится, а кто не способен, пусть постится, ибо это для него защита. Пост ломает страсть, очищает душу и приближает к Всевышнему Аллаху.'''} + ], + 'explanation': '''Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе.''' + }, + ], + 'ethics': [ + { + 'title': 'Благородный нрав', + 'text': 'قال رسول الله صلى الله عليه وآله: إنما بعثت لأتمم مكارم الأخلاق.', + 'translation': [ + {'language_code': 'ru', 'title': 'Сказал Посланник Аллаха (да благословит Аллах его и его семейство): Поистине, я послан, чтобы довести до совершенства благородные нравы.'} + ], + 'explanation': 'Этот хадис подчеркивает важность благородного нрава в Исламе.' + }, + ] + } + + # Create hadis records for each leaf category + hadis_created_count = 0 + for category in leaf_categories: + # Determine hadis type based on category title + hadis_type = 'prayer' + if any(word in category.title.lower() for word in ['пост', 'рамадан']): + hadis_type = 'fasting' + elif any(word in category.title.lower() for word in ['нрав', 'терпение', 'справедливость']): + hadis_type = 'ethics' + + # Get sample hadis for this type + samples = hadis_samples.get(hadis_type, hadis_samples['prayer']) + + # Create 10 hadis per category + for i in range(10): + sample = random.choice(samples) + + # Create unique title + hadis_title = f"{sample['title']} - {category.title} ({i+1})" + + # Check if hadis already exists + if Hadis.objects.filter(title=hadis_title, category=category).exists(): + continue + + # Create hadis record + hadis = Hadis.objects.create( + category=category, + title=hadis_title, + text=sample['text'], + translation=sample['translation'], + explanation=sample['explanation'], + status=True, # Boolean field for visibility + hadis_status=random.choice(statuses), # ForeignKey to HadisStatus + links=[ + {'title': 'Source 1', 'link': 'https://example.com/source1'}, + {'title': 'Source 2', 'link': 'https://example.com/source2'}, + ] + ) + + # Add tags + selected_tags = random.sample(tags, min(3, len(tags))) + hadis.tags.set(selected_tags) + + # Create transmission chain (5 transmitters with one gap) + selected_transmitters = random.sample(transmitters, min(5, len(transmitters))) + gap_position = random.randint(0, len(selected_transmitters) - 1) + + for j, transmitter in enumerate(selected_transmitters): + HadisTransmitter.objects.create( + hadis=hadis, + transmitter=transmitter, + order=j + 1, + is_gap=(j == gap_position) + ) + + # Create reference + reference_book = random.choice(books) + reference = HadisReference.objects.create( + hadis=hadis, + book=reference_book, + description=f"Volume {random.randint(1, 5)}, Page {random.randint(1, 1000)}, Hadis #{random.randint(1, 9999)}" + ) + + # Add reference image + if self.image_files: + image_file = random.choice(self.image_files) + try: + with open(image_file, 'rb') as f: + ReferenceImage.objects.create( + reference=reference, + thumbnail=File(f, name=f"ref_{reference.id}.png") + ) + except Exception as e: + self.stdout.write( + self.style.WARNING(f"Could not add reference image: {e}") + ) + + hadis_created_count += 1 + + if hadis_created_count % 50 == 0: + self.stdout.write(f" Created {hadis_created_count} hadis records...") + + self.stdout.write(f"Successfully created {hadis_created_count} hadis records") diff --git a/apps/hadis/management/commands/seed_hadis_transmitter.py b/apps/hadis/management/commands/seed_hadis_transmitter.py new file mode 100644 index 0000000..ec20105 --- /dev/null +++ b/apps/hadis/management/commands/seed_hadis_transmitter.py @@ -0,0 +1,91 @@ +import random +import time +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Q +# REPLACE 'your_app' with your actual app name +from apps.hadis.models import Hadis, Transmitters, NarratorLayer, HadisTransmitter + +class Command(BaseCommand): + help = 'Seeds HadisTransmitter instances with specific constraints' + + def handle(self, *args, **options): + self.stdout.write("Starting HadisTransmitter seeding...") + + # --------------------------------------------------------- + # 1. Fetch the specific Objects + # --------------------------------------------------------- + layers = list(NarratorLayer.objects.filter(id__range=(11, 15))) + transmitters = list(Transmitters.objects.filter(id__range=(84, 91))) + + # Hadis query + hadis_qs = Hadis.objects.filter( + Q(id__range=(1800, 1852)) | Q(id__range=(1877, 1889)) + ) + + # --------------------------------------------------------- + # 2. Validation + # --------------------------------------------------------- + if len(layers) < 2: + self.stdout.write(self.style.ERROR(f"Need at least 2 NarratorLayers to satisfy constraints, but found {len(layers)}.")) + return + if not transmitters: + self.stdout.write(self.style.ERROR("No Transmitters found in range 84-91.")) + return + if not hadis_qs.exists(): + self.stdout.write(self.style.ERROR("No Hadis found in the specified ranges.")) + return + + total_hadis = hadis_qs.count() + self.stdout.write(f"Found {len(layers)} Layers, {len(transmitters)} Transmitters, and {total_hadis} Hadis.") + + # --------------------------------------------------------- + # 3. Creation Loop + # --------------------------------------------------------- + created_count = 0 + + self.stdout.write("Beginning processing...") + + for i, hadis in enumerate(hadis_qs, 1): + # Print progress every 5 items so you know it's not frozen + if i % 5 == 0: + self.stdout.write(f"Processing {i}/{total_hadis} (Hadis ID: {hadis.id})...") + + # Wrap PER ITEM in transaction to avoid long db locks + with transaction.atomic(): + # CONSTRAINT 1: Each hadis must have 3-4 transmitters + chain_length = random.randint(3, 4) + + if len(transmitters) < chain_length: + self.stdout.write(self.style.WARNING(f"Not enough transmitters. Skipping Hadis {hadis.id}.")) + continue + + # Pick unique transmitters + selected_transmitters = random.sample(transmitters, chain_length) + + # CONSTRAINT 2: Transmitters must be separated to at least 2 narrator layers + # LOGIC FIX: Instead of a while loop, we force the condition explicitly. + + # Step A: Pick 2 DISTINCT layers guaranteed + guaranteed_layers = random.sample(layers, 2) + + # Step B: Fill the remaining slots (1 or 2 slots) with random layers + remaining_slots = chain_length - 2 + other_layers = [random.choice(layers) for _ in range(remaining_slots)] + + # Step C: Combine and Shuffle so the distinct ones aren't always first + final_layers = guaranteed_layers + other_layers + random.shuffle(final_layers) + + # Create the connections + for index, transmitter in enumerate(selected_transmitters): + HadisTransmitter.objects.create( + hadis=hadis, + transmitter=transmitter, + narrator_layer=final_layers[index], + order=index, + status=transmitter.reliability if hasattr(transmitter, 'reliability') else None + ) + created_count += 1 + + self.stdout.write(self.style.SUCCESS(f"Done! Successfully created {created_count} HadisTransmitter instances.")) \ No newline at end of file diff --git a/apps/hadis/management/commands/seed_images.py b/apps/hadis/management/commands/seed_images.py new file mode 100644 index 0000000..9d3cef8 --- /dev/null +++ b/apps/hadis/management/commands/seed_images.py @@ -0,0 +1,86 @@ +import os +from django.core.management.base import BaseCommand +from django.conf import settings +from django.core.files import File +# REPLACE 'your_app' WITH YOUR ACTUAL APP NAME(S) +from apps.hadis.models import BookReference, BookReferenceImage, HadisReference, ReferenceImage + +class Command(BaseCommand): + help = 'Seeds BookReferenceImage and ReferenceImage from seeds/images directory' + + def handle(self, *args, **options): + # 1. Setup Paths + base_dir = settings.BASE_DIR + seeds_path = os.path.join(base_dir, 'seeds', 'images') + + # Define source images + book_images = [f'book{i}.png' for i in range(1, 6)] # book1.png to book5.png + ref_images = [f'ref{i}.png' for i in range(1, 5)] # ref1.png to ref4.png + + # Check if directory exists + if not os.path.exists(seeds_path): + self.stdout.write(self.style.ERROR(f"Directory not found: {seeds_path}")) + return + + self.stdout.write("Starting image seeding process...") + + # --------------------------------------------------------- + # 2. Process BookReferences + # --------------------------------------------------------- + books = BookReference.objects.all() + book_count = 0 + + if not books.exists(): + self.stdout.write(self.style.WARNING("No BookReference objects found.")) + else: + for index, book in enumerate(books): + # Cycle through the 5 images using modulo operator + image_name = book_images[index % len(book_images)] + image_path = os.path.join(seeds_path, image_name) + + if os.path.exists(image_path): + with open(image_path, 'rb') as f: + # Create the instance + book_img_instance = BookReferenceImage( + book_reference=book, + order=0, + description=[{"language_code": "en", "text": f"Auto-generated image for {book.pk}"}] + ) + # Save the file content to the ImageField + # This automatically handles the upload_to path defined in your model + book_img_instance.image.save(image_name, File(f), save=True) + book_count += 1 + else: + self.stdout.write(self.style.WARNING(f"Image not found: {image_name}")) + + self.stdout.write(self.style.SUCCESS(f"Successfully created {book_count} BookReferenceImages.")) + + # --------------------------------------------------------- + # 3. Process HadisReferences (ReferenceImage) + # --------------------------------------------------------- + # Note: Your model class is 'ReferenceImage', though you asked for 'HadisReferenceImage' + refs = HadisReference.objects.all() + ref_count = 0 + + if not refs.exists(): + self.stdout.write(self.style.WARNING("No HadisReference objects found.")) + else: + for index, ref in enumerate(refs): + # Cycle through the 4 images + image_name = ref_images[index % len(ref_images)] + image_path = os.path.join(seeds_path, image_name) + + if os.path.exists(image_path): + with open(image_path, 'rb') as f: + # Create the instance + ref_img_instance = ReferenceImage( + reference=ref, + priority=0 + ) + # Your model uses 'thumbnail' field, not 'image' + ref_img_instance.thumbnail.save(image_name, File(f), save=True) + ref_count += 1 + else: + self.stdout.write(self.style.WARNING(f"Image not found: {image_name}")) + + self.stdout.write(self.style.SUCCESS(f"Successfully created {ref_count} ReferenceImages.")) \ No newline at end of file diff --git a/apps/hadis/management/commands/seed_transmitters.py b/apps/hadis/management/commands/seed_transmitters.py new file mode 100644 index 0000000..9e06710 --- /dev/null +++ b/apps/hadis/management/commands/seed_transmitters.py @@ -0,0 +1,109 @@ +import random +from django.core.management.base import BaseCommand +from django.db import transaction +# REPLACE 'apps.hadis.models' WITH YOUR ACTUAL APP PATH +from apps.hadis.models import Transmitters, TransmitterOpinion, TransmitterOriginalText, OpinionStatus + +class Command(BaseCommand): + help = 'Seeds TransmitterOpinion and TransmitterOriginalText for transmitters 84-91' + + def handle(self, *args, **options): + self.stdout.write("Starting Transmitter Details seeding...") + + # --------------------------------------------------------- + # 1. Fetch Target Objects + # --------------------------------------------------------- + transmitters = Transmitters.objects.filter(id__range=(84, 91)) + + if not transmitters.exists(): + self.stdout.write(self.style.ERROR("No Transmitters found in range 84-91.")) + return + + # Fetch OpinionStatus objects + # We need these to populate the 'status' ForeignKey in TransmitterOpinion + statuses = list(OpinionStatus.objects.all()) + + # Fallback: If no statuses exist, we can't create opinions safely without knowing your Status model fields. + if not statuses: + self.stdout.write(self.style.ERROR("No 'OpinionStatus' objects found! Please create some via Admin first.")) + return + + self.stdout.write(f"Found {transmitters.count()} Transmitters and {len(statuses)} Opinion Statuses.") + + # --------------------------------------------------------- + # 2. Data Generators (Helpers) + # --------------------------------------------------------- + scholars = [ + {"en": "Al-Dhahabi", "ar": "الذهبي", "fa": "ذهبی"}, + {"en": "Ibn Hajar", "ar": "ابن حجر", "fa": "ابن حجر"}, + {"en": "Al-Tusi", "ar": "الطوسي", "fa": "طوسی"}, + {"en": "Al-Najashi", "ar": "النجاشي", "fa": "نجاشی"}, + ] + + opinion_texts = [ + {"en": "He is trustworthy and reliable.", "ar": "هو ثقة ثبت.", "fa": "او ثقه و مورد اعتماد است."}, + {"en": "His memory was weak in later years.", "ar": "كان سيء الحفظ في آخره.", "fa": "حافظه او در اواخر عمر ضعیف بود."}, + {"en": "Unknown status.", "ar": "مجهول الحال.", "fa": "مجهول است."}, + {"en": "Highly praised by scholars.", "ar": "ممدوح عند العلماء.", "fa": "مورد ستایش علما است."}, + ] + + book_titles = [ + {"en": "Book of Traditions", "ar": "كتاب الحديث", "fa": "کتاب حدیث"}, + {"en": "Treatise on Rights", "ar": "رسالة الحقوق", "fa": "رساله حقوق"}, + {"en": "The Clarification", "ar": "التبين", "fa": "تبیین"}, + {"en": "Collection of Virtues", "ar": "مجموع الفضائل", "fa": "مجموعه فضائل"}, + ] + + def make_json_field(data_dict): + """Converts simple dict {'en': 'X', 'fa': 'Y'} to your model's JSON structure""" + return [ + {"language_code": "en", "text": data_dict.get("en", "")}, + {"language_code": "ar", "text": data_dict.get("ar", "")}, + {"language_code": "fa", "text": data_dict.get("fa", "")}, + ] + + # --------------------------------------------------------- + # 3. Creation Loop + # --------------------------------------------------------- + counts = {'opinions': 0, 'texts': 0} + + with transaction.atomic(): + for t in transmitters: + self.stdout.write(f"Processing {t.full_name[0]['text'] if t.full_name else t.id}...") + + # --- A. Create TransmitterOpinion (1 to 2 per person) --- + for _ in range(random.randint(1, 2)): + scholar = random.choice(scholars) + text = random.choice(opinion_texts) + status = random.choice(statuses) + + TransmitterOpinion.objects.create( + transmitter=t, + scholar_name=make_json_field(scholar), + opinion_text=make_json_field(text), + status=status + ) + counts['opinions'] += 1 + + # --- B. Create TransmitterOriginalText (1 to 2 per person) --- + for _ in range(random.randint(1, 2)): + title = random.choice(book_titles) + # Simple dummy text + body = { + "en": f"This is an excerpt from {title['en']}...", + "ar": f"هذا مقتطف من {title['ar']}...", + "fa": f"این بخشی از {title['fa']} است..." + } + + TransmitterOriginalText.objects.create( + transmitter=t, + title=make_json_field(title), + text=make_json_field(body), + translation=make_json_field(body), # Using same text for translation as placeholder + # Note: 'slug' and 'share_link' are handled by your model's save() method automatically + ) + counts['texts'] += 1 + + self.stdout.write(self.style.SUCCESS( + f"Done! Created {counts['opinions']} Opinions and {counts['texts']} Original Texts." + )) \ No newline at end of file diff --git a/apps/hadis/management/commands/slug_hadis.py b/apps/hadis/management/commands/slug_hadis.py new file mode 100644 index 0000000..a72fca4 --- /dev/null +++ b/apps/hadis/management/commands/slug_hadis.py @@ -0,0 +1,257 @@ +""" +Django Management Command: Regenerate TransmitterOriginalText Slugs + +This command: +1. Takes all existing TransmitterOriginalText objects +2. Extracts their title (first N words, default 8) +3. Generates smart, short, meaningful slugs +4. Handles uniqueness with counters (-1, -2, -3, etc.) +5. REPLACES all old slug values with new ones +6. Detects and numbers duplicates automatically + +Usage: + python manage.py regenerate_transmitter_originaltext_slugs + python manage.py regenerate_transmitter_originaltext_slugs --max-length 75 + python manage.py regenerate_transmitter_originaltext_slugs --keep-words 6 + python manage.py regenerate_transmitter_originaltext_slugs --dry-run +""" + +from collections import defaultdict + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils.text import slugify + +from apps.hadis.models import TransmitterOriginalText +from utils.slug import generate_smart_slug # your existing helper + + +class Command(BaseCommand): + help = ( + "Regenerate smart slugs for all TransmitterOriginalText objects. " + "Replaces existing slug values with optimized, short, meaningful ones. " + "Automatically adds counters for duplicates." + ) + + def add_arguments(self, parser): + """Add optional command-line arguments""" + parser.add_argument( + "--max-length", + type=int, + default=100, + help="Maximum slug length (default: 100)", + ) + parser.add_argument( + "--keep-words", + type=int, + default=8, + help="Maximum number of words to keep in slug (default: 8)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without saving", + ) + + @transaction.atomic + def handle(self, *args, **options): + max_length = options["max_length"] + keep_words = options["keep_words"] + dry_run = options["dry_run"] + + self.stdout.write(self.style.HTTP_INFO("=" * 80)) + self.stdout.write("🔄 TRANSMITTER ORIGINAL TEXT SLUG REGENERATION\n") + self.stdout.write("Configuration:") + self.stdout.write(f" • Max length: {max_length} chars") + self.stdout.write(f" • Keep words: {keep_words} words") + self.stdout.write(f" • Dry run: {'Yes (no changes)' if dry_run else 'No (will save)'}") + self.stdout.write(self.style.HTTP_INFO("=" * 80) + "\n") + + # Get all objects + qs = TransmitterOriginalText.objects.all().order_by("id") + total = qs.count() + + self.stdout.write(f"Step 1: Analyzing {total} TransmitterOriginalText objects...\n") + + # Dictionary to track duplicate slugs: {base_slug: [ids]} + slug_map = defaultdict(list) + obj_slug_map = {} # {id: data} + + # First pass: Generate base slugs and identify duplicates + for obj in qs: + try: + # Extract title text from JSONField + title_text = None + if obj.title and isinstance(obj.title, list) and obj.title: + first_item = obj.title[0] + if isinstance(first_item, dict): + title_text = first_item.get("text") + + # Fallback if no title + if not title_text: + # use transmitter name if available, otherwise id + base = None + if obj.transmitter and isinstance(obj.transmitter.full_name, list): + first_name = obj.transmitter.full_name[0] + if isinstance(first_name, dict): + base = first_name.get("text") + title_text = base or f"transmitter-originaltext-{obj.id}" + + # Generate base slug (without counter) + base_slug = self._generate_base_slug( + title_text, + max_length - 5, # Reserve space for counter + keep_words, + ) + + obj_slug_map[obj.id] = { + "base_slug": base_slug, + "title_text": title_text, + "old_slug": obj.slug or "(empty)", + } + + slug_map[base_slug].append(obj.id) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Error analyzing TransmitterOriginalText {obj.id}: {str(e)}") + ) + + self.stdout.write("✅ Analysis complete!\n") + self.stdout.write("Step 2: Detecting duplicates...\n") + + # Identify groups with duplicates + duplicate_groups = { + slug: ids for slug, ids in slug_map.items() if len(ids) > 1 + } + + if duplicate_groups: + self.stdout.write( + self.style.WARNING( + f"⚠️ Found {len(duplicate_groups)} groups with duplicate slugs:\n" + ) + ) + for base_slug, ids in sorted(duplicate_groups.items()): + self.stdout.write(f" • '{base_slug}' → {len(ids)} objects") + for idx, obj_id in enumerate(ids): + counter = "" if idx == 0 else f"-{idx}" + self.stdout.write( + f" - TransmitterOriginalText {obj_id}: {base_slug}{counter}" + ) + else: + self.stdout.write("✅ No duplicates found! All slugs are unique.\n") + + self.stdout.write("\nStep 3: Applying slugs with counters...\n") + + # Second pass: Apply slugs with counters for duplicates + updated = 0 + unchanged = 0 + errors = [] + + for obj in qs: + try: + slug_info = obj_slug_map.get(obj.id) + if not slug_info: + continue + + base_slug = slug_info["base_slug"] + old_slug = slug_info["old_slug"] + + # If this slug has duplicates, add counter + if len(slug_map[base_slug]) > 1: + duplicate_ids = slug_map[base_slug] + position = duplicate_ids.index(obj.id) + + # First one gets no counter, rest get -1, -2, etc. + if position == 0: + new_slug = base_slug + else: + counter_suffix = f"-{position}" + available_length = max_length - len(counter_suffix) + new_slug = ( + base_slug[:available_length].rstrip("-") + + counter_suffix + ) + else: + new_slug = base_slug + + changed = obj.slug != new_slug + + if changed: + self.stdout.write( + f" [{obj.id:5d}] {old_slug:45s} → {new_slug}" + ) + + if not dry_run: + obj.slug = new_slug + obj.save(update_fields=["slug"]) + updated += 1 + else: + unchanged += 1 + + except Exception as e: + error_msg = f"TransmitterOriginalText {obj.id}: {str(e)}" + self.stdout.write(self.style.ERROR(f" ❌ {error_msg}")) + errors.append(error_msg) + + # Summary + self.stdout.write("\n" + self.style.HTTP_INFO("=" * 80)) + self.stdout.write("📊 RESULTS\n") + self.stdout.write(f" ✅ Updated: {updated}") + self.stdout.write(f" ➡️ Unchanged: {unchanged}") + self.stdout.write(f" ❌ Errors: {len(errors)}") + self.stdout.write( + f" 🔢 Duplicate groups handled: {len(duplicate_groups)}" + ) + + if dry_run: + self.stdout.write( + self.style.WARNING( + "\n ⚠️ DRY RUN MODE: No changes were saved." + ) + ) + self.stdout.write( + self.style.WARNING( + " Run without --dry-run to apply changes." + ) + ) + + if errors: + self.stdout.write(self.style.ERROR("\n❌ ERRORS ENCOUNTERED:")) + for error in errors: + self.stdout.write(f" • {error}") + + self.stdout.write(self.style.HTTP_INFO("=" * 80) + "\n") + + if not dry_run and updated > 0: + self.stdout.write( + self.style.SUCCESS( + f"✅ Successfully regenerated {updated} slug(s) for TransmitterOriginalText!" + ) + ) + elif dry_run: + self.stdout.write( + self.style.HTTP_INFO( + f"ℹ️ Would update {updated} slug(s) in production run." + ) + ) + + def _generate_base_slug(self, text: str, max_length: int, keep_words: int) -> str: + """ + Generate a base slug without counter. + Returns the base slug that might be used for multiple objects. + """ + if not text or not isinstance(text, str): + return "transmitter-originaltext-unknown" + + # Extract first N words + words = text.strip().split()[:keep_words] + text_shortened = " ".join(words) + + # Slugify + base_slug = slugify(text_shortened, allow_unicode=True) + + # Truncate + slug = base_slug[:max_length].rstrip("-") + + return slug diff --git a/apps/hadis/management/commands/test_safe_seeding.py b/apps/hadis/management/commands/test_safe_seeding.py new file mode 100644 index 0000000..e771c5c --- /dev/null +++ b/apps/hadis/management/commands/test_safe_seeding.py @@ -0,0 +1,152 @@ +""" +Test safe seeding with lock detection and retry logic +""" + +import time +from django.core.management.base import BaseCommand +from django.db import connection +from django.db.utils import OperationalError, IntegrityError +from apps.hadis.models import HadisSect, HadisStatus, HadisTag + + +class Command(BaseCommand): + help = 'Test safe seeding with lock detection' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.retry_delay = 1 # seconds + self.max_retries = 3 + + def handle(self, **options): + self.stdout.write("🧪 Testing safe seeding with lock detection...") + + # Check database status + self.check_database_locks() + + # Test creating a few records + self.test_sect_creation() + self.test_status_creation() + self.test_tag_creation() + + self.stdout.write(self.style.SUCCESS("✅ All tests completed successfully!")) + + def safe_execute_with_retry(self, operation_name, operation_func, *args, **kwargs): + """Execute database operation with retry logic for handling locks""" + for attempt in range(self.max_retries): + try: + self.stdout.write(f" Attempting {operation_name} (attempt {attempt + 1}/{self.max_retries})") + result = operation_func(*args, **kwargs) + self.stdout.write(f" ✓ {operation_name} completed successfully") + return result + + except OperationalError as e: + error_msg = str(e).lower() + if 'database is locked' in error_msg or 'deadlock' in error_msg: + self.stdout.write( + self.style.WARNING( + f" ⚠ Database lock detected in {operation_name}: {str(e)}" + ) + ) + if attempt < self.max_retries - 1: + self.stdout.write(f" ⏳ Waiting {self.retry_delay} seconds before retry...") + time.sleep(self.retry_delay) + self.retry_delay = min(self.retry_delay * 1.5, 5) + else: + self.stdout.write( + self.style.ERROR(f" ❌ Max retries reached for {operation_name}") + ) + raise + else: + self.stdout.write( + self.style.ERROR(f" ❌ Non-lock error in {operation_name}: {str(e)}") + ) + raise + + except IntegrityError as e: + if 'unique' in str(e).lower() or 'duplicate' in str(e).lower(): + self.stdout.write( + self.style.WARNING(f" ⚠ Record already exists in {operation_name}: {str(e)}") + ) + return None + else: + self.stdout.write( + self.style.ERROR(f" ❌ Integrity error in {operation_name}: {str(e)}") + ) + raise + + except Exception as e: + self.stdout.write( + self.style.ERROR(f" ❌ Unexpected error in {operation_name}: {str(e)}") + ) + raise + + def check_database_locks(self): + """Check for existing database locks""" + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1;") + cursor.fetchone() + self.stdout.write("✓ Database connection is working") + + except Exception as e: + self.stdout.write( + self.style.WARNING(f"Could not check database: {str(e)}") + ) + + def create_test_sect(self): + """Create a test sect""" + sect, created = HadisSect.objects.get_or_create( + sect_type='test', + defaults={ + 'title': 'Test Sect', + 'is_active': True, + 'order': 999 + } + ) + if created: + self.stdout.write(" ✅ Created test sect") + else: + self.stdout.write(" ✓ Test sect already exists") + return sect + + def create_test_status(self): + """Create a test status""" + status, created = HadisStatus.objects.get_or_create( + title='Test Status', + defaults={ + 'color': 'blue', + 'order': 999 + } + ) + if created: + self.stdout.write(" ✅ Created test status") + else: + self.stdout.write(" ✓ Test status already exists") + return status + + def create_test_tag(self): + """Create a test tag""" + tag, created = HadisTag.objects.get_or_create( + title='Test Tag', + defaults={'status': True} + ) + if created: + self.stdout.write(" ✅ Created test tag") + else: + self.stdout.write(" ✓ Test tag already exists") + return tag + + def test_sect_creation(self): + """Test sect creation with retry logic""" + self.stdout.write("🕌 Testing sect creation...") + self.safe_execute_with_retry("Create test sect", self.create_test_sect) + + def test_status_creation(self): + """Test status creation with retry logic""" + self.stdout.write("📊 Testing status creation...") + self.safe_execute_with_retry("Create test status", self.create_test_status) + + def test_tag_creation(self): + """Test tag creation with retry logic""" + self.stdout.write("🏷️ Testing tag creation...") + self.safe_execute_with_retry("Create test tag", self.create_test_tag) diff --git a/apps/hadis/management/commands/test_sects.py b/apps/hadis/management/commands/test_sects.py new file mode 100644 index 0000000..21fa5a5 --- /dev/null +++ b/apps/hadis/management/commands/test_sects.py @@ -0,0 +1,45 @@ +from django.core.management.base import BaseCommand +from apps.hadis.models import HadisSect + + +class Command(BaseCommand): + help = 'Test sects creation' + + def handle(self, **options): + _ = options # Suppress unused variable warning + self.stdout.write("Testing sects creation...") + + try: + # Check existing sects + existing_sects = HadisSect.objects.all() + self.stdout.write(f"Found {existing_sects.count()} existing sects:") + for sect in existing_sects: + self.stdout.write(f" - {sect.sect_type}: {sect.title}") + + # Try to create sunni sect using direct create + self.stdout.write("Attempting to create sunni sect...") + + # Check if sunni exists + try: + sunni_sect = HadisSect.objects.get(sect_type='sunni') + self.stdout.write(f"Sunni sect already exists: {sunni_sect.title}") + except HadisSect.DoesNotExist: + # Create sunni sect + self.stdout.write("Creating new sunni sect...") + sunni_sect = HadisSect( + sect_type='sunni', + title='Сунниты', + is_active=True, + order=2 + ) + sunni_sect.save() + self.stdout.write(self.style.SUCCESS(f"Created sunni sect: {sunni_sect.title}")) + + # Final count + final_count = HadisSect.objects.count() + self.stdout.write(f"Total sects after operation: {final_count}") + + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error: {str(e)}")) + import traceback + self.stdout.write(self.style.ERROR(traceback.format_exc())) diff --git a/apps/hadis/migrations/0001_initial.py b/apps/hadis/migrations/0001_initial.py index 0cd0e90..8c95bbe 100644 --- a/apps/hadis/migrations/0001_initial.py +++ b/apps/hadis/migrations/0001_initial.py @@ -1,8 +1,18 @@ +<<<<<<< HEAD # Generated by Django 3.2.7 on 2025-03-16 23:50 from django.db import migrations, models import django.db.models.deletion import mptt.fields +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.image +import mptt.fields +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -10,10 +20,16 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD +======= + ('library', '0001_initial'), + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), +>>>>>>> develop ] operations = [ migrations.CreateModel( +<<<<<<< HEAD name='Hadis', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -31,10 +47,13 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +======= +>>>>>>> develop name='HadisTag', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=355, verbose_name='title')), +<<<<<<< HEAD ], ), migrations.CreateModel( @@ -61,6 +80,20 @@ class Migration(migrations.Migration): ('source_type', models.CharField(blank=True, choices=[('shia', 'Shia Sources'), ('sunni', 'Sunni Sources')], default='shia', max_length=10, verbose_name='Source Type')), ('category_type', models.CharField(blank=True, choices=[('quran', 'Quran'), ('hadith', 'Hadith')], max_length=10, null=True, verbose_name='Category Content Type')), ('title', models.CharField(max_length=355, verbose_name='title')), +======= + ('status', models.BooleanField(default=True, verbose_name='status')), + ], + ), + migrations.CreateModel( + name='HadisCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(default=True, verbose_name='is active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('source_type', models.CharField(blank=True, choices=[('shia', 'Shia'), ('sunni', 'Sunni')], default='shia', max_length=10, verbose_name='Source Type')), + ('category_type', models.CharField(blank=True, choices=[('quran', 'Quran'), ('hadith', 'Hadith')], max_length=10, null=True, verbose_name='Category Content Type')), + ('name', models.CharField(max_length=355, verbose_name='name')), +>>>>>>> develop ('order', models.IntegerField(default=0, verbose_name='order')), ('lft', models.PositiveIntegerField(editable=False)), ('rght', models.PositiveIntegerField(editable=False)), @@ -74,6 +107,7 @@ class Migration(migrations.Migration): 'ordering': ('order',), }, ), +<<<<<<< HEAD migrations.AddField( model_name='hadis', name='category', @@ -83,5 +117,97 @@ class Migration(migrations.Migration): model_name='hadis', name='tags', field=models.ManyToManyField(related_name='hadises', through='hadis.HadisTagRelation', to='hadis.HadisTag', verbose_name='tags'), +======= + migrations.CreateModel( + name='Hadis', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.PositiveIntegerField(unique=True, verbose_name='number')), + ('title', models.CharField(max_length=355, verbose_name='title')), + ('text', models.TextField(verbose_name='text')), + ('translation', models.TextField(blank=True, default='', verbose_name='translation')), + ('status', models.BooleanField(default=True, verbose_name='visibility')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.hadiscategory', verbose_name='category')), + ], + options={ + 'verbose_name': 'hadis', + 'verbose_name_plural': 'hadises', + }, + ), + migrations.CreateModel( + name='HadisReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hadis_references', to='library.book', verbose_name='book')), + ('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.hadis', verbose_name='hadis')), + ], + options={ + 'verbose_name': 'Hadis Reference', + 'verbose_name_plural': 'Hadis References', + 'unique_together': {('hadis', 'book')}, + }, + ), + migrations.CreateModel( + name='ReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.IntegerField(default=0, help_text='Priority of the image, lower values mean higher priority.', verbose_name='Priority')), + ('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hadis.hadisreference', verbose_name='Hadis Reference')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')), + ], + options={ + 'verbose_name': 'Reference Image', + 'verbose_name_plural': 'Reference Images', + }, + ), + migrations.CreateModel( + name='Transmitters', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=255)), + ('birth_year_hijri', models.IntegerField(verbose_name='Birth Year (Hijri)')), + ('death_year_hijri', models.IntegerField(verbose_name='Death Year (Hijri)')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('status', models.CharField(max_length=50, verbose_name='status')), + ('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.FILER_IMAGE_MODEL)), + ], + ), + migrations.CreateModel( + name='HadisOverview', + fields=[ + ('hadis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='hadis.hadis')), + ('status', models.CharField(max_length=50, verbose_name='status')), + ('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')), + ('status_text', models.TextField(blank=True, null=True, verbose_name='Status Text')), + ('address', models.TextField(blank=True, null=True, verbose_name='address')), + ('links', models.JSONField(blank=True, default=dict, null=True, verbose_name='title')), + ('share_link', models.CharField(blank=True, max_length=255, null=True, verbose_name='share link')), + ('explanation', models.TextField(blank=True, null=True, verbose_name='explanation')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('tags', models.ManyToManyField(blank=True, related_name='hadises', to='hadis.hadistag', verbose_name='tags')), + ], + ), + migrations.CreateModel( + name='HadisTransmitter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('order', models.PositiveIntegerField(default=0, help_text='Order in the chain of transmission', verbose_name='Order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transmitters', to='hadis.hadis', verbose_name='hadis')), + ('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter')), + ], + options={ + 'verbose_name': 'Hadis Transmitter', + 'verbose_name_plural': 'Hadis Transmitters', + 'ordering': ('hadis', 'order'), + 'unique_together': {('hadis', 'transmitter', 'order')}, + }, +>>>>>>> develop ), ] diff --git a/apps/hadis/migrations/0002_hadissect_hadisstatus_alter_hadis_options_and_more.py b/apps/hadis/migrations/0002_hadissect_hadisstatus_alter_hadis_options_and_more.py new file mode 100644 index 0000000..77bc3f2 --- /dev/null +++ b/apps/hadis/migrations/0002_hadissect_hadisstatus_alter_hadis_options_and_more.py @@ -0,0 +1,217 @@ +# Generated by Django 5.1.8 on 2025-07-04 11:34 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='HadisSect', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sect_type', models.CharField(choices=[('shia', 'Shia'), ('sunni', 'Sunni')], max_length=10, unique=True, verbose_name='Sect Name')), + ('title', models.CharField(max_length=256, verbose_name='Name')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ], + options={ + 'verbose_name': 'Hadis Sect', + 'verbose_name_plural': 'Hadis Sects', + 'ordering': ('order',), + }, + ), + migrations.CreateModel( + name='HadisStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=119, verbose_name='title')), + ('color', models.CharField(choices=[('red', 'Red'), ('green', 'Green'), ('blue', 'Blue'), ('yellow', 'Yellow'), ('orange', 'Orange'), ('purple', 'Purple'), ('gray', 'Gray')], max_length=20, verbose_name='color')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ], + options={ + 'verbose_name': 'hadis status', + 'verbose_name_plural': 'hadis statuses', + 'ordering': ('order',), + }, + ), + migrations.AlterModelOptions( + name='hadis', + options={'ordering': ('category', 'number'), 'verbose_name': 'hadis', 'verbose_name_plural': 'hadises'}, + ), + migrations.AlterModelOptions( + name='transmitters', + options={'ordering': ('full_name',), 'verbose_name': 'Transmitter', 'verbose_name_plural': 'Transmitters'}, + ), + migrations.RemoveField( + model_name='hadiscategory', + name='category_type', + ), + migrations.RemoveField( + model_name='hadiscategory', + name='created_at', + ), + migrations.RemoveField( + model_name='hadiscategory', + name='is_active', + ), + migrations.RemoveField( + model_name='hadiscategory', + name='name', + ), + migrations.RemoveField( + model_name='hadistransmitter', + name='description', + ), + migrations.RemoveField( + model_name='transmitters', + name='status', + ), + migrations.RemoveField( + model_name='transmitters', + name='status_color', + ), + migrations.AddField( + model_name='hadis', + name='address', + field=models.TextField(blank=True, null=True, verbose_name='address'), + ), + migrations.AddField( + model_name='hadis', + name='explanation', + field=models.TextField(blank=True, null=True, verbose_name='explanation'), + ), + migrations.AddField( + model_name='hadis', + name='hadis_status_text', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='hadis status text'), + ), + migrations.AddField( + model_name='hadis', + name='links', + field=models.JSONField(blank=True, default=dict, null=True, verbose_name='links'), + ), + migrations.AddField( + model_name='hadis', + name='share_link', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='share link'), + ), + migrations.AddField( + model_name='hadis', + name='tags', + field=models.ManyToManyField(blank=True, related_name='hadis_overview', to='hadis.hadistag', verbose_name='tags'), + ), + migrations.AddField( + model_name='hadiscategory', + name='title', + field=models.CharField(default='Default Category', max_length=256, verbose_name='Title'), + preserve_default=False, + ), + migrations.AddField( + model_name='hadiscategory', + name='xmind_file', + field=models.FileField(blank=True, null=True, upload_to='hadis/xmind_files/', verbose_name='xmind file'), + ), + migrations.AddField( + model_name='hadistag', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='hadistag', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='updated at'), + ), + migrations.AddField( + model_name='hadistransmitter', + name='is_gap', + field=models.BooleanField(default=False, help_text='Check this if this represents a gap in the transmission chain', verbose_name='Is Gap'), + ), + migrations.AddField( + model_name='transmitters', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='created at'), + preserve_default=False, + ), + migrations.AddField( + model_name='transmitters', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='updated at'), + ), + migrations.AlterField( + model_name='hadis', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.hadiscategory', verbose_name='category'), + ), + migrations.AlterField( + model_name='hadis', + name='number', + field=models.PositiveIntegerField(default=1, verbose_name='number'), + ), + migrations.AlterField( + model_name='hadis', + name='title', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'), + ), + migrations.AlterField( + model_name='hadis', + name='translation', + field=models.JSONField(default=list, verbose_name='translation'), + ), + migrations.AlterField( + model_name='hadiscategory', + name='source_type', + field=models.CharField(choices=[('quran', 'Quran'), ('hadith', 'Hadith')], max_length=10, verbose_name='Source Type'), + ), + migrations.AlterField( + model_name='hadistransmitter', + name='transmitter', + field=models.ForeignKey(blank=True, help_text='Leave empty if this represents a gap in the chain', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter'), + ), + migrations.AlterField( + model_name='referenceimage', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='hadis/reference_images/', verbose_name='thumbnail'), + ), + migrations.AlterField( + model_name='transmitters', + name='birth_year_hijri', + field=models.IntegerField(blank=True, null=True, verbose_name='Birth Year (Hijri)'), + ), + migrations.AlterField( + model_name='transmitters', + name='death_year_hijri', + field=models.IntegerField(blank=True, null=True, verbose_name='Death Year (Hijri)'), + ), + migrations.AlterField( + model_name='transmitters', + name='full_name', + field=models.CharField(max_length=255, verbose_name='full name'), + ), + migrations.AddField( + model_name='hadiscategory', + name='sect', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='hadis.hadissect', verbose_name='Sect'), + preserve_default=False, + ), + migrations.AddField( + model_name='hadis', + name='hadis_status', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.hadisstatus', verbose_name='hadis status'), + ), + migrations.AddField( + model_name='hadistransmitter', + name='status', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transmitters', to='hadis.hadisstatus', verbose_name='status'), + ), + migrations.DeleteModel( + name='HadisOverview', + ), + ] diff --git a/apps/hadis/migrations/0003_bookreference_narratorlayer_and_more.py b/apps/hadis/migrations/0003_bookreference_narratorlayer_and_more.py new file mode 100644 index 0000000..2650a1b --- /dev/null +++ b/apps/hadis/migrations/0003_bookreference_narratorlayer_and_more.py @@ -0,0 +1,226 @@ +# Generated by Django 5.1.8 on 2025-12-03 23:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0002_hadissect_hadisstatus_alter_hadis_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BookReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=500, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('language', models.CharField(blank=True, max_length=100, null=True, verbose_name='language')), + ('isbn', models.CharField(blank=True, max_length=100, null=True, verbose_name='ISBN')), + ('volume', models.CharField(blank=True, max_length=100, null=True, verbose_name='volume')), + ('year_of_publication', models.CharField(blank=True, max_length=50, null=True, verbose_name='year of publication')), + ('number_page', models.PositiveIntegerField(blank=True, null=True, verbose_name='number of pages')), + ('rate', models.DecimalField(blank=True, decimal_places=2, help_text='Rating from 0 to 5', max_digits=3, null=True, verbose_name='rate')), + ('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': 'Book Reference', + 'verbose_name_plural': 'Book References', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='NarratorLayer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('number', models.PositiveIntegerField(unique=True, verbose_name='layer number')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Narrator Layer', + 'verbose_name_plural': 'Narrator Layers', + 'ordering': ['number'], + }, + ), + migrations.AlterUniqueTogether( + name='hadisreference', + unique_together=set(), + ), + migrations.RemoveField( + model_name='hadistransmitter', + name='is_gap', + ), + migrations.AddField( + model_name='hadis', + name='title_narrator', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title narrator'), + ), + migrations.AddField( + model_name='hadiscategory', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='hadissect', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='transmitters', + name='age_at_death', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Age at Death'), + ), + migrations.AddField( + model_name='transmitters', + name='died_in', + field=models.CharField(blank=True, help_text='Place of death', max_length=255, null=True, verbose_name='Died In'), + ), + migrations.AddField( + model_name='transmitters', + name='in_sahih_bukhari', + field=models.BooleanField(default=False, help_text='Is this narrator present in Sahih Bukhari?', verbose_name='In Sahih Bukhari'), + ), + migrations.AddField( + model_name='transmitters', + name='in_sahih_muslim', + field=models.BooleanField(default=False, help_text='Is this narrator present in Sahih Muslim?', verbose_name='In Sahih Muslim'), + ), + migrations.AddField( + model_name='transmitters', + name='known_as', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Known As'), + ), + migrations.AddField( + model_name='transmitters', + name='kunya', + field=models.CharField(blank=True, help_text='e.g., Abu Abdullah', max_length=255, null=True, verbose_name='Kunya'), + ), + migrations.AddField( + model_name='transmitters', + name='lived_in', + field=models.CharField(blank=True, help_text='Places where they lived', max_length=255, null=True, verbose_name='Lived In'), + ), + migrations.AddField( + model_name='transmitters', + name='madhhab', + field=models.CharField(choices=[('shia', 'Shia'), ('sunni', 'Sunni'), ('hanafi', 'Hanafi'), ('maliki', 'Maliki'), ('shafii', "Shafi'i"), ('hanbali', 'Hanbali'), ('other', 'Other'), ('unknown', 'Unknown')], default='unknown', max_length=20, verbose_name='Madhhab/School of Thought'), + ), + migrations.AddField( + model_name='transmitters', + name='nickname', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Nickname/Laqab'), + ), + migrations.AddField( + model_name='transmitters', + name='origin', + field=models.CharField(blank=True, help_text='Place of origin', max_length=255, null=True, verbose_name='Origin'), + ), + migrations.AddField( + model_name='transmitters', + name='reliability', + field=models.CharField(choices=[('very_reliable', 'Very Reliable'), ('reliable', 'Reliable'), ('acceptable', 'Acceptable'), ('weak', 'Weak'), ('very_weak', 'Very Weak'), ('unknown', 'Unknown')], default='unknown', max_length=20, verbose_name='Reliability Level'), + ), + migrations.AlterField( + model_name='hadiscategory', + name='source_type', + field=models.CharField(choices=[('quran', 'Quran'), ('hadith', 'Hadith'), ('history', 'History'), ('fatwa', 'Fatwa'), ('quote', 'Quote')], max_length=10, verbose_name='Source Type'), + ), + migrations.AlterField( + model_name='hadistransmitter', + name='status', + field=models.CharField(choices=[('reliable', 'Reliable'), ('weak', 'Weak'), ('unknown', 'Unknown')], default='unknown', help_text='Reliability status of the narrator', max_length=20, verbose_name='reliability status'), + ), + migrations.AlterField( + model_name='hadistransmitter', + name='transmitter', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter'), + ), + migrations.CreateModel( + name='BookAuthor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('book_references', models.ManyToManyField(blank=True, related_name='authors', to='hadis.bookreference', verbose_name='book references')), + ], + options={ + 'verbose_name': 'Book Author', + 'verbose_name_plural': 'Book Authors', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='BookAttribute', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('value', models.CharField(max_length=500, verbose_name='value')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('book_references', models.ManyToManyField(blank=True, related_name='attributes', to='hadis.bookreference', verbose_name='book references')), + ], + options={ + 'verbose_name': 'Book Attribute', + 'verbose_name_plural': 'Book Attributes', + 'ordering': ['title'], + }, + ), + migrations.AddField( + model_name='hadisreference', + name='book_reference', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hadis_references', to='hadis.bookreference', verbose_name='book reference'), + ), + migrations.CreateModel( + name='BookReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='hadis/book_reference_images/', verbose_name='image')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('description', models.CharField(blank=True, max_length=255, null=True, verbose_name='description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('book_reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='hadis.bookreference', verbose_name='book reference')), + ], + options={ + 'verbose_name': 'Book Reference Image', + 'verbose_name_plural': 'Book Reference Images', + 'ordering': ['order', '-created_at'], + }, + ), + migrations.AddField( + model_name='hadistransmitter', + name='narrator_layer', + field=models.ForeignKey(blank=True, help_text='The layer/class (Tabaqah) this narrator belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transmitters', to='hadis.narratorlayer', verbose_name='narrator layer'), + ), + migrations.CreateModel( + name='TransmitterOpinion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scholar_name', models.CharField(help_text='Name of the scholar who gave this opinion', max_length=255, verbose_name='Scholar Name')), + ('opinion_text', models.TextField(help_text="The scholar's opinion about this transmitter", verbose_name='Opinion Text')), + ('status', models.CharField(choices=[('confirmed', 'Confirmed'), ('mixed', 'Mixed'), ('rejected', 'Rejected')], default='confirmed', help_text='Status of the opinion', max_length=20, verbose_name='Opinion Status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opinions', to='hadis.transmitters', verbose_name='transmitter')), + ], + options={ + 'verbose_name': 'Transmitter Opinion', + 'verbose_name_plural': 'Transmitter Opinions', + 'ordering': ('-created_at',), + }, + ), + migrations.RemoveField( + model_name='hadisreference', + name='book', + ), + migrations.RemoveField( + model_name='hadisreference', + name='description', + ), + ] diff --git a/apps/hadis/migrations/0004_hadiscollection_hadisincollection.py b/apps/hadis/migrations/0004_hadiscollection_hadisincollection.py new file mode 100644 index 0000000..733ea2e --- /dev/null +++ b/apps/hadis/migrations/0004_hadiscollection_hadisincollection.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2.4 on 2025-12-05 17:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ('hadis', '0003_bookreference_narratorlayer_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='HadisCollection', + 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(blank=True, max_length=255, unique=True, verbose_name='slug')), + ('summary', models.TextField(blank=True, null=True, verbose_name='summary')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('order', models.IntegerField(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')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='thumbnail image', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')), + ], + options={ + 'verbose_name': 'hadis collection', + 'verbose_name_plural': 'hadis collections', + 'ordering': ('order',), + }, + ), + migrations.CreateModel( + name='HadisInCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadis_items', to='hadis.hadiscollection', verbose_name='collection')), + ('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_items', to='hadis.hadis', verbose_name='hadis')), + ], + options={ + 'verbose_name': 'hadis in collection', + 'verbose_name_plural': 'hadis in collections', + 'ordering': ('order',), + 'unique_together': {('hadis', 'collection')}, + }, + ), + ] diff --git a/apps/hadis/migrations/0005_auto_20251209_1620.py b/apps/hadis/migrations/0005_auto_20251209_1620.py new file mode 100644 index 0000000..60ec132 --- /dev/null +++ b/apps/hadis/migrations/0005_auto_20251209_1620.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.9 on 2025-12-09 16:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0004_hadiscollection_hadisincollection"), + ] + + operations = [ + migrations.AddField( + model_name='hadiscategory', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='hadissect', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='Description'), + ), + ] diff --git a/apps/hadis/migrations/0006_hadiscategory_slug.py b/apps/hadis/migrations/0006_hadiscategory_slug.py new file mode 100644 index 0000000..9520717 --- /dev/null +++ b/apps/hadis/migrations/0006_hadiscategory_slug.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-11 13:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0005_auto_20251209_1620"), + ] + + operations = [ + migrations.AddField( + model_name="hadiscategory", + name="slug", + field=models.SlugField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/hadis/migrations/0007_auto_20251211_1313.py b/apps/hadis/migrations/0007_auto_20251211_1313.py new file mode 100644 index 0000000..2d5574c --- /dev/null +++ b/apps/hadis/migrations/0007_auto_20251211_1313.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2025-12-11 13:13 + +from django.db import migrations +from django.utils.text import slugify + +def gen_slugs(apps, schema_editor): + MyModel = apps.get_model('hadis', 'HadisCategory') # Replace with your app/model name + for row in MyModel.objects.all(): + # 1. Basic slugify + base_slug = slugify(row.title) # Assuming you slugify the 'name' field + slug = base_slug + n = 1 + + # 2. Ensure uniqueness (if two rows have the same name) + while MyModel.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{n}" + n += 1 + + row.slug = slug + row.save() + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0005_auto_20251209_1620"), + ] + + operations = [ + migrations.RunPython(gen_slugs, reverse_code=migrations.RunPython.noop), + ] diff --git a/apps/hadis/migrations/0007_auto_20251211_1324.py b/apps/hadis/migrations/0007_auto_20251211_1324.py new file mode 100644 index 0000000..6171ad5 --- /dev/null +++ b/apps/hadis/migrations/0007_auto_20251211_1324.py @@ -0,0 +1,29 @@ +from django.db import migrations +from django.utils.text import slugify + +def gen_slugs(apps, schema_editor): + MyModel = apps.get_model('hadis', 'HadisCategory') # Replace with your app/model name + for row in MyModel.objects.all(): + # 1. Basic slugify + base_slug = slugify(row.title) # Assuming you slugify the 'name' field + slug = base_slug + n = 1 + + # 2. Ensure uniqueness (if two rows have the same name) + while MyModel.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{n}" + n += 1 + + row.slug = slug + row.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0006_hadiscategory_slug'), + ] + + operations = [ + # Call the function + migrations.RunPython(gen_slugs, reverse_code=migrations.RunPython.noop), + ] \ No newline at end of file diff --git a/apps/hadis/migrations/0008_alter_hadiscategory_slug.py b/apps/hadis/migrations/0008_alter_hadiscategory_slug.py new file mode 100644 index 0000000..6ec70ff --- /dev/null +++ b/apps/hadis/migrations/0008_alter_hadiscategory_slug.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-11 13:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0007_auto_20251211_1324"), + ] + + operations = [ + migrations.AlterField( + model_name="hadiscategory", + name="slug", + field=models.SlugField(blank=True, max_length=255, null=True, unique=True), + ), + ] diff --git a/apps/hadis/migrations/0009_alter_hadiscategory_slug.py b/apps/hadis/migrations/0009_alter_hadiscategory_slug.py new file mode 100644 index 0000000..4d2d53e --- /dev/null +++ b/apps/hadis/migrations/0009_alter_hadiscategory_slug.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-11 13:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0008_alter_hadiscategory_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="hadiscategory", + name="slug", + field=models.SlugField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/hadis/migrations/0010_merge_20251211_1555.py b/apps/hadis/migrations/0010_merge_20251211_1555.py new file mode 100644 index 0000000..82d8247 --- /dev/null +++ b/apps/hadis/migrations/0010_merge_20251211_1555.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.9 on 2025-12-11 15:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0007_auto_20251211_1313"), + ("hadis", "0009_alter_hadiscategory_slug"), + ] + + operations = [] diff --git a/apps/hadis/migrations/0011_hadis_a.py b/apps/hadis/migrations/0011_hadis_a.py new file mode 100644 index 0000000..7da9edb --- /dev/null +++ b/apps/hadis/migrations/0011_hadis_a.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-11 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0010_merge_20251211_1555"), + ] + + operations = [ + migrations.AddField( + model_name="hadis", + name="a", + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/apps/hadis/migrations/0012_remove_hadis_title_narrator.py b/apps/hadis/migrations/0012_remove_hadis_title_narrator.py new file mode 100644 index 0000000..74dc3e7 --- /dev/null +++ b/apps/hadis/migrations/0012_remove_hadis_title_narrator.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-11 16:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0011_hadis_a"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadis", + name="title_narrator", + ), + ] diff --git a/apps/hadis/migrations/0013_hadis_title_narrator.py b/apps/hadis/migrations/0013_hadis_title_narrator.py new file mode 100644 index 0000000..719b544 --- /dev/null +++ b/apps/hadis/migrations/0013_hadis_title_narrator.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-11 16:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0012_remove_hadis_title_narrator"), + ] + + operations = [ + migrations.AddField( + model_name="hadis", + name="title_narrator", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="title narrator" + ), + ), + ] diff --git a/apps/hadis/migrations/0014_remove_hadistransmitter_narrator_layer.py b/apps/hadis/migrations/0014_remove_hadistransmitter_narrator_layer.py new file mode 100644 index 0000000..5262724 --- /dev/null +++ b/apps/hadis/migrations/0014_remove_hadistransmitter_narrator_layer.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-13 08:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0013_hadis_title_narrator"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadistransmitter", + name="narrator_layer", + ), + ] diff --git a/apps/hadis/migrations/0015_hadistransmitter_narrator_layer.py b/apps/hadis/migrations/0015_hadistransmitter_narrator_layer.py new file mode 100644 index 0000000..2e955e2 --- /dev/null +++ b/apps/hadis/migrations/0015_hadistransmitter_narrator_layer.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.9 on 2025-12-13 08:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0014_remove_hadistransmitter_narrator_layer"), + ] + + operations = [ + migrations.AddField( + model_name="hadistransmitter", + name="narrator_layer", + field=models.ForeignKey( + blank=True, + help_text="The layer/class (Tabaqah) this narrator belongs to", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="transmitters", + to="hadis.narratorlayer", + verbose_name="narrator layer", + ), + ), + ] diff --git a/apps/hadis/migrations/0016_remove_hadistransmitter_narrator_layer_and_more.py b/apps/hadis/migrations/0016_remove_hadistransmitter_narrator_layer_and_more.py new file mode 100644 index 0000000..79cc759 --- /dev/null +++ b/apps/hadis/migrations/0016_remove_hadistransmitter_narrator_layer_and_more.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-13 08:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0015_hadistransmitter_narrator_layer"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadistransmitter", + name="narrator_layer", + ), + migrations.DeleteModel( + name="NarratorLayer", + ), + ] diff --git a/apps/hadis/migrations/0017_narratorlayer_hadistransmitter_narrator_layer.py b/apps/hadis/migrations/0017_narratorlayer_hadistransmitter_narrator_layer.py new file mode 100644 index 0000000..19eeee9 --- /dev/null +++ b/apps/hadis/migrations/0017_narratorlayer_hadistransmitter_narrator_layer.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.9 on 2025-12-13 08:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0016_remove_hadistransmitter_narrator_layer_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="NarratorLayer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="name")), + ( + "number", + models.PositiveIntegerField( + unique=True, verbose_name="layer number" + ), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="description"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + ], + options={ + "verbose_name": "Narrator Layer", + "verbose_name_plural": "Narrator Layers", + "ordering": ["number"], + }, + ), + migrations.AddField( + model_name="hadistransmitter", + name="narrator_layer", + field=models.ForeignKey( + blank=True, + help_text="The layer/class (Tabaqah) this narrator belongs to", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="transmitters", + to="hadis.narratorlayer", + verbose_name="narrator layer", + ), + ), + ] diff --git a/apps/hadis/migrations/0018_remove_hadistransmitter_status.py b/apps/hadis/migrations/0018_remove_hadistransmitter_status.py new file mode 100644 index 0000000..622e121 --- /dev/null +++ b/apps/hadis/migrations/0018_remove_hadistransmitter_status.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-13 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0017_narratorlayer_hadistransmitter_narrator_layer"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadistransmitter", + name="status", + ), + ] diff --git a/apps/hadis/migrations/0019_hadistransmitter_status.py b/apps/hadis/migrations/0019_hadistransmitter_status.py new file mode 100644 index 0000000..6d2cf04 --- /dev/null +++ b/apps/hadis/migrations/0019_hadistransmitter_status.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2025-12-13 09:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0018_remove_hadistransmitter_status"), + ] + + operations = [ + migrations.AddField( + model_name="hadistransmitter", + name="status", + field=models.CharField( + choices=[ + ("reliable", "Reliable"), + ("weak", "Weak"), + ("unknown", "Unknown"), + ], + default="unknown", + help_text="Reliability status of the narrator", + max_length=20, + verbose_name="reliability status", + ), + ), + ] diff --git a/apps/hadis/migrations/0020_hadisreference_description.py b/apps/hadis/migrations/0020_hadisreference_description.py new file mode 100644 index 0000000..1dd2842 --- /dev/null +++ b/apps/hadis/migrations/0020_hadisreference_description.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-13 09:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0019_hadistransmitter_status"), + ] + + operations = [ + migrations.AddField( + model_name="hadisreference", + name="description", + field=models.TextField(blank=True, null=True, verbose_name="description"), + ), + ] diff --git a/apps/hadis/migrations/0021_remove_hadisreference_book_reference_and_more.py b/apps/hadis/migrations/0021_remove_hadisreference_book_reference_and_more.py new file mode 100644 index 0000000..72df463 --- /dev/null +++ b/apps/hadis/migrations/0021_remove_hadisreference_book_reference_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.9 on 2025-12-13 09:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0020_hadisreference_description"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadisreference", + name="book_reference", + ), + migrations.RemoveField( + model_name="hadisreference", + name="description", + ), + ] diff --git a/apps/hadis/migrations/0022_hadisreference_book_reference_and_more.py b/apps/hadis/migrations/0022_hadisreference_book_reference_and_more.py new file mode 100644 index 0000000..b08c1d9 --- /dev/null +++ b/apps/hadis/migrations/0022_hadisreference_book_reference_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.9 on 2025-12-13 09:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0021_remove_hadisreference_book_reference_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="hadisreference", + name="book_reference", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hadis_references", + to="hadis.bookreference", + verbose_name="book reference", + ), + ), + migrations.AddField( + model_name="hadisreference", + name="description", + field=models.TextField(blank=True, null=True, verbose_name="description"), + ), + ] diff --git a/apps/hadis/migrations/0023_remove_hadisreference_book_reference_and_more.py b/apps/hadis/migrations/0023_remove_hadisreference_book_reference_and_more.py new file mode 100644 index 0000000..21fa282 --- /dev/null +++ b/apps/hadis/migrations/0023_remove_hadisreference_book_reference_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0022_hadisreference_book_reference_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadisreference", + name="book_reference", + ), + migrations.AddField( + model_name="hadisreference", + name="book", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hadis_references", + to="hadis.bookreference", + verbose_name="book reference", + ), + ), + ] diff --git a/apps/hadis/migrations/0024_remove_hadisreference_book.py b/apps/hadis/migrations/0024_remove_hadisreference_book.py new file mode 100644 index 0000000..82716e5 --- /dev/null +++ b/apps/hadis/migrations/0024_remove_hadisreference_book.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0023_remove_hadisreference_book_reference_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadisreference", + name="book", + ), + ] diff --git a/apps/hadis/migrations/0025_hadisreference_book_reference.py b/apps/hadis/migrations/0025_hadisreference_book_reference.py new file mode 100644 index 0000000..c8e4844 --- /dev/null +++ b/apps/hadis/migrations/0025_hadisreference_book_reference.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0024_remove_hadisreference_book"), + ] + + operations = [ + migrations.AddField( + model_name="hadisreference", + name="book_reference", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hadis_references", + to="hadis.bookreference", + verbose_name="book reference", + ), + ), + ] diff --git a/apps/hadis/migrations/0026_remove_hadisreference_book_reference.py b/apps/hadis/migrations/0026_remove_hadisreference_book_reference.py new file mode 100644 index 0000000..cdb9c5e --- /dev/null +++ b/apps/hadis/migrations/0026_remove_hadisreference_book_reference.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0025_hadisreference_book_reference"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadisreference", + name="book_reference", + ), + ] diff --git a/apps/hadis/migrations/0027_hadisreference_book_reference.py b/apps/hadis/migrations/0027_hadisreference_book_reference.py new file mode 100644 index 0000000..c017d46 --- /dev/null +++ b/apps/hadis/migrations/0027_hadisreference_book_reference.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0026_remove_hadisreference_book_reference"), + ] + + operations = [ + migrations.AddField( + model_name="hadisreference", + name="book_reference", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hadis_references", + to="hadis.bookreference", + verbose_name="book reference", + ), + ), + ] diff --git a/apps/hadis/migrations/0028_hadistransmitter_description.py b/apps/hadis/migrations/0028_hadistransmitter_description.py new file mode 100644 index 0000000..236aeed --- /dev/null +++ b/apps/hadis/migrations/0028_hadistransmitter_description.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0027_hadisreference_book_reference"), + ] + + operations = [ + migrations.AddField( + model_name="hadistransmitter", + name="description", + field=models.TextField(blank=True, null=True, verbose_name="description"), + ), + ] diff --git a/apps/hadis/migrations/0029_remove_hadistransmitter_description_and_more.py b/apps/hadis/migrations/0029_remove_hadistransmitter_description_and_more.py new file mode 100644 index 0000000..b0ab814 --- /dev/null +++ b/apps/hadis/migrations/0029_remove_hadistransmitter_description_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0028_hadistransmitter_description"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadistransmitter", + name="description", + ), + migrations.AddField( + model_name="hadistransmitter", + name="is_gap", + field=models.BooleanField(default=False, verbose_name="is gap"), + ), + ] diff --git a/apps/hadis/migrations/0030_remove_hadistransmitter_is_gap.py b/apps/hadis/migrations/0030_remove_hadistransmitter_is_gap.py new file mode 100644 index 0000000..7b05eba --- /dev/null +++ b/apps/hadis/migrations/0030_remove_hadistransmitter_is_gap.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0029_remove_hadistransmitter_description_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadistransmitter", + name="is_gap", + ), + ] diff --git a/apps/hadis/migrations/0031_hadistransmitter_is_gap.py b/apps/hadis/migrations/0031_hadistransmitter_is_gap.py new file mode 100644 index 0000000..77a7f46 --- /dev/null +++ b/apps/hadis/migrations/0031_hadistransmitter_is_gap.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0030_remove_hadistransmitter_is_gap"), + ] + + operations = [ + migrations.AddField( + model_name="hadistransmitter", + name="is_gap", + field=models.BooleanField(default=False, verbose_name="is gap"), + ), + ] diff --git a/apps/hadis/migrations/0032_remove_hadis_a_hadis_description.py b/apps/hadis/migrations/0032_remove_hadis_a_hadis_description.py new file mode 100644 index 0000000..4cd1086 --- /dev/null +++ b/apps/hadis/migrations/0032_remove_hadis_a_hadis_description.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.9 on 2025-12-13 10:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0031_hadistransmitter_is_gap"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadis", + name="a", + ), + migrations.AddField( + model_name="hadis", + name="description", + field=models.TextField(blank=True, null=True, verbose_name="description"), + ), + ] diff --git a/apps/hadis/migrations/0033_hadiscorrection.py b/apps/hadis/migrations/0033_hadiscorrection.py new file mode 100644 index 0000000..84df46f --- /dev/null +++ b/apps/hadis/migrations/0033_hadiscorrection.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.9 on 2025-12-13 11:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0032_remove_hadis_a_hadis_description"), + ] + + operations = [ + migrations.CreateModel( + name="HadisCorrection", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="title")), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="description"), + ), + ( + "translation", + models.JSONField(default=list, verbose_name="translation"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + ( + "hadis", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="hadis.hadis", + verbose_name="hadis correction", + ), + ), + ], + options={ + "verbose_name": "Hadis Correction", + "verbose_name_plural": "Hadis Corrections", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apps/hadis/migrations/0034_hadiscorrection_share_link.py b/apps/hadis/migrations/0034_hadiscorrection_share_link.py new file mode 100644 index 0000000..871cf59 --- /dev/null +++ b/apps/hadis/migrations/0034_hadiscorrection_share_link.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-14 10:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0033_hadiscorrection"), + ] + + operations = [ + migrations.AddField( + model_name="hadiscorrection", + name="share_link", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="share link" + ), + ), + ] diff --git a/apps/hadis/migrations/0035_transmitteroriginaltext.py b/apps/hadis/migrations/0035_transmitteroriginaltext.py new file mode 100644 index 0000000..063edea --- /dev/null +++ b/apps/hadis/migrations/0035_transmitteroriginaltext.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.9 on 2025-12-14 11:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0034_hadiscorrection_share_link"), + ] + + operations = [ + migrations.CreateModel( + name="TransmitterOriginalText", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "title", + models.CharField( + blank=True, max_length=255, null=True, verbose_name="title" + ), + ), + ("text", models.TextField(verbose_name="text")), + ( + "translation", + models.JSONField(default=list, verbose_name="translation"), + ), + ( + "share_link", + models.CharField( + blank=True, max_length=255, null=True, verbose_name="share link" + ), + ), + ( + "transmitter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="originaltextes", + to="hadis.transmitters", + verbose_name="transmitter", + ), + ), + ], + ), + ] diff --git a/apps/hadis/migrations/0036_transmitters_generation_and_more.py b/apps/hadis/migrations/0036_transmitters_generation_and_more.py new file mode 100644 index 0000000..0d3d15b --- /dev/null +++ b/apps/hadis/migrations/0036_transmitters_generation_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.9 on 2025-12-14 14:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0035_transmitteroriginaltext"), + ] + + operations = [ + migrations.AddField( + model_name="transmitters", + name="generation", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Generation" + ), + ), + migrations.AlterField( + model_name="transmitteroriginaltext", + name="transmitter", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="originaltexts", + to="hadis.transmitters", + verbose_name="transmitter", + ), + ), + ] diff --git a/apps/hadis/migrations/0037_remove_bookattribute_book_references_and_more.py b/apps/hadis/migrations/0037_remove_bookattribute_book_references_and_more.py new file mode 100644 index 0000000..c8410fb --- /dev/null +++ b/apps/hadis/migrations/0037_remove_bookattribute_book_references_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2025-12-14 15:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0036_transmitters_generation_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="bookattribute", + name="book_references", + ), + migrations.AddField( + model_name="bookattribute", + name="book_reference", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="attributes", + to="hadis.bookreference", + verbose_name="book attribute", + ), + ), + ] diff --git a/apps/hadis/migrations/0038_narratorlayer_slug_alter_referenceimage_reference.py b/apps/hadis/migrations/0038_narratorlayer_slug_alter_referenceimage_reference.py new file mode 100644 index 0000000..3ac0326 --- /dev/null +++ b/apps/hadis/migrations/0038_narratorlayer_slug_alter_referenceimage_reference.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.9 on 2025-12-16 10:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0037_remove_bookattribute_book_references_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="narratorlayer", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + migrations.AlterField( + model_name="referenceimage", + name="reference", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="hadis.hadisreference", + verbose_name="Hadis Reference", + ), + ), + ] diff --git a/apps/hadis/migrations/0039_alter_narratorlayer_slug.py b/apps/hadis/migrations/0039_alter_narratorlayer_slug.py new file mode 100644 index 0000000..e88c8a8 --- /dev/null +++ b/apps/hadis/migrations/0039_alter_narratorlayer_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-16 10:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0038_narratorlayer_slug_alter_referenceimage_reference"), + ] + + operations = [ + migrations.AlterField( + model_name="narratorlayer", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0040_bookreference_slug.py b/apps/hadis/migrations/0040_bookreference_slug.py new file mode 100644 index 0000000..628da2c --- /dev/null +++ b/apps/hadis/migrations/0040_bookreference_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-16 12:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0039_alter_narratorlayer_slug"), + ] + + operations = [ + migrations.AddField( + model_name="bookreference", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0041_remove_bookreference_slug.py b/apps/hadis/migrations/0041_remove_bookreference_slug.py new file mode 100644 index 0000000..b6ef66a --- /dev/null +++ b/apps/hadis/migrations/0041_remove_bookreference_slug.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-16 12:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0040_bookreference_slug"), + ] + + operations = [ + migrations.RemoveField( + model_name="bookreference", + name="slug", + ), + ] diff --git a/apps/hadis/migrations/0042_bookreference_slug.py b/apps/hadis/migrations/0042_bookreference_slug.py new file mode 100644 index 0000000..93806e8 --- /dev/null +++ b/apps/hadis/migrations/0042_bookreference_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-16 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0041_remove_bookreference_slug"), + ] + + operations = [ + migrations.AddField( + model_name="bookreference", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0043_bookreference_publisher.py b/apps/hadis/migrations/0043_bookreference_publisher.py new file mode 100644 index 0000000..074088a --- /dev/null +++ b/apps/hadis/migrations/0043_bookreference_publisher.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-16 12:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0042_bookreference_slug"), + ] + + operations = [ + migrations.AddField( + model_name="bookreference", + name="publisher", + field=models.TextField(blank=True, null=True, verbose_name="publisher"), + ), + ] diff --git a/apps/hadis/migrations/0044_remove_bookreference_publisher.py b/apps/hadis/migrations/0044_remove_bookreference_publisher.py new file mode 100644 index 0000000..57bc5bc --- /dev/null +++ b/apps/hadis/migrations/0044_remove_bookreference_publisher.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-16 12:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0043_bookreference_publisher"), + ] + + operations = [ + migrations.RemoveField( + model_name="bookreference", + name="publisher", + ), + ] diff --git a/apps/hadis/migrations/0045_bookreference_publisher.py b/apps/hadis/migrations/0045_bookreference_publisher.py new file mode 100644 index 0000000..245210c --- /dev/null +++ b/apps/hadis/migrations/0045_bookreference_publisher.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-16 12:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0044_remove_bookreference_publisher"), + ] + + operations = [ + migrations.AddField( + model_name="bookreference", + name="publisher", + field=models.TextField(blank=True, null=True, verbose_name="publisher"), + ), + ] diff --git a/apps/hadis/migrations/0046_transmitters_slug.py b/apps/hadis/migrations/0046_transmitters_slug.py new file mode 100644 index 0000000..6ae908a --- /dev/null +++ b/apps/hadis/migrations/0046_transmitters_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-17 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0045_bookreference_publisher"), + ] + + operations = [ + migrations.AddField( + model_name="transmitters", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0047_remove_transmitters_slug.py b/apps/hadis/migrations/0047_remove_transmitters_slug.py new file mode 100644 index 0000000..e4fd14e --- /dev/null +++ b/apps/hadis/migrations/0047_remove_transmitters_slug.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.9 on 2025-12-17 13:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0046_transmitters_slug"), + ] + + operations = [ + migrations.RemoveField( + model_name="transmitters", + name="slug", + ), + ] diff --git a/apps/hadis/migrations/0048_transmitters_slug.py b/apps/hadis/migrations/0048_transmitters_slug.py new file mode 100644 index 0000000..0980607 --- /dev/null +++ b/apps/hadis/migrations/0048_transmitters_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-17 13:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0047_remove_transmitters_slug"), + ] + + operations = [ + migrations.AddField( + model_name="transmitters", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0049_alter_transmitters_slug.py b/apps/hadis/migrations/0049_alter_transmitters_slug.py new file mode 100644 index 0000000..d8c1263 --- /dev/null +++ b/apps/hadis/migrations/0049_alter_transmitters_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-17 13:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0048_transmitters_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="transmitters", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0050_convert_title_to_json.py b/apps/hadis/migrations/0050_convert_title_to_json.py new file mode 100644 index 0000000..75c0a63 --- /dev/null +++ b/apps/hadis/migrations/0050_convert_title_to_json.py @@ -0,0 +1,32 @@ +from django.db import migrations +import json + +def convert_to_json(apps, schema_editor): + """Convert CharField strings to JSON array of language objects""" + HadisCategory = apps.get_model('hadis', 'HadisCategory') + + for obj in HadisCategory.objects.all(): + # Convert plain string to JSON array with language objects + if obj.title and isinstance(obj.title, str): + # Wrap string in JSON format: [{"text": "string", "language_code": "en"}] + obj.title = [{"text": obj.title, "language_code": "en"}] + obj.save(update_fields=['title']) + +def reverse_convert(apps, schema_editor): + """Revert JSON back to plain text (optional)""" + HadisCategory = apps.get_model('hadis', 'HadisCategory') + + for obj in HadisCategory.objects.all(): + if obj.title and isinstance(obj.title, list) and len(obj.title) > 0: + # Extract first language's text + obj.title = obj.title[0].get('text', '') + obj.save(update_fields=['title']) + +class Migration(migrations.Migration): + dependencies = [ + ('hadis', '0049_alter_transmitters_slug'), # Adjust to your last working migration + ] + + operations = [ + migrations.RunPython(convert_to_json, reverse_convert), + ] diff --git a/apps/hadis/migrations/0051_convert_title_to_json_fixed.py b/apps/hadis/migrations/0051_convert_title_to_json_fixed.py new file mode 100644 index 0000000..04744de --- /dev/null +++ b/apps/hadis/migrations/0051_convert_title_to_json_fixed.py @@ -0,0 +1,40 @@ +from django.db import migrations +import json + +def convert_to_json(apps, schema_editor): + """Convert CharField strings to JSON array of language objects""" + HadisCategory = apps.get_model('hadis', 'HadisCategory') + + for obj in HadisCategory.objects.all(): + # Convert plain string to JSON array with language objects + if obj.title and isinstance(obj.title, str): + # Create Python dict, then serialize to valid JSON + title_data = [{"text": obj.title, "language_code": "en"}] + # CRITICAL: Use json.dumps() to convert to valid JSON string + obj.title = json.dumps(title_data) + obj.save(update_fields=['title']) + +def reverse_convert(apps, schema_editor): + """Revert JSON back to plain text (optional)""" + HadisCategory = apps.get_model('hadis', 'HadisCategory') + + for obj in HadisCategory.objects.all(): + if obj.title: + try: + # Parse JSON string back to Python object + title_data = json.loads(obj.title) if isinstance(obj.title, str) else obj.title + if isinstance(title_data, list) and len(title_data) > 0: + # Extract first language's text + obj.title = title_data[0].get('text', '') + obj.save(update_fields=['title']) + except (json.JSONDecodeError, TypeError): + pass + +class Migration(migrations.Migration): + dependencies = [ + ('hadis', '0050_convert_title_to_json'), # Adjust to your last working migration + ] + + operations = [ + migrations.RunPython(convert_to_json, reverse_convert), + ] diff --git a/apps/hadis/migrations/0052_alter_hadiscategory_title.py b/apps/hadis/migrations/0052_alter_hadiscategory_title.py new file mode 100644 index 0000000..864a12a --- /dev/null +++ b/apps/hadis/migrations/0052_alter_hadiscategory_title.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-17 15:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0051_convert_title_to_json_fixed"), + ] + + operations = [ + migrations.AlterField( + model_name="hadiscategory", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + ] diff --git a/apps/hadis/migrations/0053_alter_hadiscategory_description_and_more.py b/apps/hadis/migrations/0053_alter_hadiscategory_description_and_more.py new file mode 100644 index 0000000..542947e --- /dev/null +++ b/apps/hadis/migrations/0053_alter_hadiscategory_description_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2025-12-18 10:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0052_alter_hadiscategory_title"), + ] + + operations = [ + migrations.AlterField( + model_name="hadiscategory", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="hadissect", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="hadissect", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + ] diff --git a/apps/hadis/migrations/0054_remove_hadiscorrection_description_and_more.py b/apps/hadis/migrations/0054_remove_hadiscorrection_description_and_more.py new file mode 100644 index 0000000..78f1ac4 --- /dev/null +++ b/apps/hadis/migrations/0054_remove_hadiscorrection_description_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.2.9 on 2025-12-18 10:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0053_alter_hadiscategory_description_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadiscorrection", + name="description", + ), + migrations.AlterField( + model_name="hadis", + name="address", + field=models.JSONField(default=list, verbose_name="Address"), + ), + migrations.AlterField( + model_name="hadis", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="hadis", + name="explanation", + field=models.JSONField(default=list, verbose_name="Explanation"), + ), + migrations.AlterField( + model_name="hadis", + name="hadis_status_text", + field=models.JSONField(default=list, verbose_name="Status text"), + ), + migrations.AlterField( + model_name="hadis", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="hadis", + name="title_narrator", + field=models.JSONField(default=list, verbose_name="Title Narrator"), + ), + migrations.AlterField( + model_name="hadiscollection", + name="summary", + field=models.JSONField(default=list, verbose_name="Summary"), + ), + migrations.AlterField( + model_name="hadiscollection", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="hadiscorrection", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="hadisreference", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="hadisstatus", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="hadistag", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + ] diff --git a/apps/hadis/migrations/0055_alter_bookattribute_title_alter_bookattribute_value_and_more.py b/apps/hadis/migrations/0055_alter_bookattribute_title_alter_bookattribute_value_and_more.py new file mode 100644 index 0000000..db3aa43 --- /dev/null +++ b/apps/hadis/migrations/0055_alter_bookattribute_title_alter_bookattribute_value_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2025-12-18 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0054_remove_hadiscorrection_description_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="bookattribute", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="bookattribute", + name="value", + field=models.JSONField(default=list, verbose_name="Value"), + ), + migrations.AlterField( + model_name="bookauthor", + name="name", + field=models.JSONField(default=list, verbose_name="Name"), + ), + migrations.AlterField( + model_name="bookreference", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="bookreference", + name="language", + field=models.JSONField(default=list, verbose_name="Language"), + ), + migrations.AlterField( + model_name="bookreference", + name="publisher", + field=models.JSONField(default=list, verbose_name="Publisher"), + ), + migrations.AlterField( + model_name="bookreference", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="bookreferenceimage", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + ] diff --git a/apps/hadis/migrations/0056_alter_narratorlayer_description_and_more.py b/apps/hadis/migrations/0056_alter_narratorlayer_description_and_more.py new file mode 100644 index 0000000..196c94a --- /dev/null +++ b/apps/hadis/migrations/0056_alter_narratorlayer_description_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.9 on 2025-12-18 10:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0055_alter_bookattribute_title_alter_bookattribute_value_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="narratorlayer", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="narratorlayer", + name="name", + field=models.JSONField(default=list, verbose_name="Name"), + ), + migrations.AlterField( + model_name="transmitteropinion", + name="opinion_text", + field=models.JSONField(default=list, verbose_name="Opinion Text"), + ), + migrations.AlterField( + model_name="transmitteropinion", + name="scholar_name", + field=models.JSONField(default=list, verbose_name="Scholar Name"), + ), + migrations.AlterField( + model_name="transmitteroriginaltext", + name="text", + field=models.JSONField(default=list, verbose_name="Text"), + ), + migrations.AlterField( + model_name="transmitteroriginaltext", + name="title", + field=models.JSONField(default=list, verbose_name="Title"), + ), + migrations.AlterField( + model_name="transmitters", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + migrations.AlterField( + model_name="transmitters", + name="died_in", + field=models.JSONField(default=list, verbose_name="Died in"), + ), + migrations.AlterField( + model_name="transmitters", + name="full_name", + field=models.JSONField(default=list, verbose_name="Full Name"), + ), + migrations.AlterField( + model_name="transmitters", + name="known_as", + field=models.JSONField(default=list, verbose_name="Known as"), + ), + migrations.AlterField( + model_name="transmitters", + name="kunya", + field=models.JSONField(default=list, verbose_name="Kunya"), + ), + migrations.AlterField( + model_name="transmitters", + name="lived_in", + field=models.JSONField(default=list, verbose_name="Lived in"), + ), + migrations.AlterField( + model_name="transmitters", + name="nickname", + field=models.JSONField(default=list, verbose_name="Nick Name"), + ), + migrations.AlterField( + model_name="transmitters", + name="origin", + field=models.JSONField(default=list, verbose_name="Origin"), + ), + ] diff --git a/apps/hadis/migrations/0057_hadiscorrection_description.py b/apps/hadis/migrations/0057_hadiscorrection_description.py new file mode 100644 index 0000000..0017439 --- /dev/null +++ b/apps/hadis/migrations/0057_hadiscorrection_description.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-18 12:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0056_alter_narratorlayer_description_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="hadiscorrection", + name="description", + field=models.JSONField(default=list, verbose_name="Description"), + ), + ] diff --git a/apps/hadis/migrations/0058_hadis_slug.py b/apps/hadis/migrations/0058_hadis_slug.py new file mode 100644 index 0000000..f0d11b4 --- /dev/null +++ b/apps/hadis/migrations/0058_hadis_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-22 08:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0057_hadiscorrection_description"), + ] + + operations = [ + migrations.AddField( + model_name="hadis", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0059_alter_hadis_slug.py b/apps/hadis/migrations/0059_alter_hadis_slug.py new file mode 100644 index 0000000..cb291df --- /dev/null +++ b/apps/hadis/migrations/0059_alter_hadis_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-22 09:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0058_hadis_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="hadis", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0060_hadiscorrection_slug.py b/apps/hadis/migrations/0060_hadiscorrection_slug.py new file mode 100644 index 0000000..7ef44f5 --- /dev/null +++ b/apps/hadis/migrations/0060_hadiscorrection_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-22 11:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0059_alter_hadis_slug"), + ] + + operations = [ + migrations.AddField( + model_name="hadiscorrection", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0061_alter_hadiscorrection_slug.py b/apps/hadis/migrations/0061_alter_hadiscorrection_slug.py new file mode 100644 index 0000000..0402bd5 --- /dev/null +++ b/apps/hadis/migrations/0061_alter_hadiscorrection_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-22 12:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0060_hadiscorrection_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="hadiscorrection", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0062_transmitteroriginaltext_slug.py b/apps/hadis/migrations/0062_transmitteroriginaltext_slug.py new file mode 100644 index 0000000..a24f475 --- /dev/null +++ b/apps/hadis/migrations/0062_transmitteroriginaltext_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-22 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0061_alter_hadiscorrection_slug"), + ] + + operations = [ + migrations.AddField( + model_name="transmitteroriginaltext", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0063_alter_transmitteroriginaltext_slug.py b/apps/hadis/migrations/0063_alter_transmitteroriginaltext_slug.py new file mode 100644 index 0000000..7dc9e2b --- /dev/null +++ b/apps/hadis/migrations/0063_alter_transmitteroriginaltext_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-22 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0062_transmitteroriginaltext_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="transmitteroriginaltext", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0064_hadis_hadis_hadis_status_5e0de5_idx.py b/apps/hadis/migrations/0064_hadis_hadis_hadis_status_5e0de5_idx.py new file mode 100644 index 0000000..acdc62e --- /dev/null +++ b/apps/hadis/migrations/0064_hadis_hadis_hadis_status_5e0de5_idx.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.27 on 2025-12-22 13:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0063_alter_transmitteroriginaltext_slug"), + ] + + operations = [ + migrations.AddIndex( + model_name="hadis", + index=models.Index( + fields=["status", "id"], name="hadis_hadis_status_5e0de5_idx" + ), + ), + ] diff --git a/apps/hadis/migrations/0065_hadiscategory_hadis_hadis_parent__e7a217_idx_and_more.py b/apps/hadis/migrations/0065_hadiscategory_hadis_hadis_parent__e7a217_idx_and_more.py new file mode 100644 index 0000000..04940b3 --- /dev/null +++ b/apps/hadis/migrations/0065_hadiscategory_hadis_hadis_parent__e7a217_idx_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.27 on 2025-12-22 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0064_hadis_hadis_hadis_status_5e0de5_idx"), + ] + + operations = [ + migrations.AddIndex( + model_name="hadiscategory", + index=models.Index( + fields=["parent", "sect"], name="hadis_hadis_parent__e7a217_idx" + ), + ), + migrations.AddIndex( + model_name="hadiscategory", + index=models.Index( + fields=["sect", "order"], name="hadis_hadis_sect_id_b57c1d_idx" + ), + ), + ] diff --git a/apps/hadis/migrations/0066_alter_transmitteroriginaltext_options_and_more.py b/apps/hadis/migrations/0066_alter_transmitteroriginaltext_options_and_more.py new file mode 100644 index 0000000..6dcfc78 --- /dev/null +++ b/apps/hadis/migrations/0066_alter_transmitteroriginaltext_options_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.27 on 2025-12-22 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0065_hadiscategory_hadis_hadis_parent__e7a217_idx_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="transmitteroriginaltext", + options={ + "verbose_name": "Transmitter Original Text", + "verbose_name_plural": "Transmitter Original Text", + }, + ), + migrations.AddIndex( + model_name="transmitteropinion", + index=models.Index( + fields=["transmitter"], name="hadis_trans_transmi_0f1df2_idx" + ), + ), + migrations.AddIndex( + model_name="transmitteroriginaltext", + index=models.Index( + fields=["transmitter"], name="hadis_trans_transmi_fff93f_idx" + ), + ), + migrations.AddIndex( + model_name="transmitters", + index=models.Index(fields=["id"], name="hadis_trans_id_bd318c_idx"), + ), + ] diff --git a/apps/hadis/migrations/0067_bookreference_hadis_bookr_id_1b53f6_idx_and_more.py b/apps/hadis/migrations/0067_bookreference_hadis_bookr_id_1b53f6_idx_and_more.py new file mode 100644 index 0000000..0c90fcc --- /dev/null +++ b/apps/hadis/migrations/0067_bookreference_hadis_bookr_id_1b53f6_idx_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.27 on 2025-12-22 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0066_alter_transmitteroriginaltext_options_and_more"), + ] + + operations = [ + migrations.AddIndex( + model_name="bookreference", + index=models.Index(fields=["id"], name="hadis_bookr_id_1b53f6_idx"), + ), + migrations.AddIndex( + model_name="hadisreference", + index=models.Index( + fields=["book_reference"], name="hadis_hadis_book_re_3fb4f0_idx" + ), + ), + ] diff --git a/apps/hadis/migrations/0068_transmitterreliability.py b/apps/hadis/migrations/0068_transmitterreliability.py new file mode 100644 index 0000000..c9c8583 --- /dev/null +++ b/apps/hadis/migrations/0068_transmitterreliability.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.27 on 2025-12-23 08:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0067_bookreference_hadis_bookr_id_1b53f6_idx_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="TransmitterReliability", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.JSONField(default=list, verbose_name="Title")), + ( + "color", + models.CharField( + choices=[ + ("red", "Red"), + ("green", "Green"), + ("blue", "Blue"), + ("yellow", "Yellow"), + ("orange", "Orange"), + ("purple", "Purple"), + ("gray", "Gray"), + ], + max_length=20, + verbose_name="color", + ), + ), + ], + options={ + "verbose_name": "transmitter reliability", + "verbose_name_plural": "transmitter reliability", + }, + ), + ] diff --git a/apps/hadis/migrations/0069_alter_transmitters_reliability.py b/apps/hadis/migrations/0069_alter_transmitters_reliability.py new file mode 100644 index 0000000..12168d7 --- /dev/null +++ b/apps/hadis/migrations/0069_alter_transmitters_reliability.py @@ -0,0 +1,134 @@ +# Generated by Django 4.2.27 on 2025-12-23 08:32 + +from django.db import migrations, models +import django.db.models.deletion + + +def create_reliability_objects(apps, schema_editor): + """Create TransmitterReliability objects for each reliability level""" + TransmitterReliability = apps.get_model('hadis', 'TransmitterReliability') + + # Define the reliability levels with their data + reliability_data = [ + { + 'title': [ + {'text': 'Very Reliable', 'language_code': 'en'}, + {'text': 'بسیار قابل اعتماد', 'language_code': 'fa'}, + {'text': 'Очень надежный', 'language_code': 'ru'} + ], + 'color': 'green', + 'value': 'very_reliable' + }, + { + 'title': [ + {'text': 'Reliable', 'language_code': 'en'}, + {'text': 'قابل اعتماد', 'language_code': 'fa'}, + {'text': 'Надежный', 'language_code': 'ru'} + ], + 'color': 'blue', + 'value': 'reliable' + }, + { + 'title': [ + {'text': 'Acceptable', 'language_code': 'en'}, + {'text': 'قابل قبول', 'language_code': 'fa'}, + {'text': 'Приемлемый', 'language_code': 'ru'} + ], + 'color': 'yellow', + 'value': 'acceptable' + }, + { + 'title': [ + {'text': 'Weak', 'language_code': 'en'}, + {'text': 'ضعیف', 'language_code': 'fa'}, + {'text': 'Слабый', 'language_code': 'ru'} + ], + 'color': 'orange', + 'value': 'weak' + }, + { + 'title': [ + {'text': 'Very Weak', 'language_code': 'en'}, + {'text': 'بسیار ضعیف', 'language_code': 'fa'}, + {'text': 'Очень слабый', 'language_code': 'ru'} + ], + 'color': 'red', + 'value': 'very_weak' + }, + { + 'title': [ + {'text': 'Unknown', 'language_code': 'en'}, + {'text': 'نامشخص', 'language_code': 'fa'}, + {'text': 'Неизвестный', 'language_code': 'ru'} + ], + 'color': 'gray', + 'value': 'unknown' + } + ] + + reliability_objects = {} + for data in reliability_data: + obj = TransmitterReliability.objects.create( + title=data['title'], + color=data['color'] + ) + reliability_objects[data['value']] = obj + + return reliability_objects + + +def migrate_transmitter_data(apps, schema_editor): + """Migrate existing transmitter reliability data""" + Transmitters = apps.get_model('hadis', 'Transmitters') + + # Create reliability objects + reliability_objects = create_reliability_objects(apps, schema_editor) + + # Update all transmitters to use the new temporary field + for transmitter in Transmitters.objects.all(): + old_value = getattr(transmitter, 'reliability', None) + if old_value and old_value in reliability_objects: + transmitter.reliability_new = reliability_objects[old_value] + else: + # Default to unknown if no value or invalid value + transmitter.reliability_new = reliability_objects['unknown'] + transmitter.save() + + +def reverse_migrate(apps, schema_editor): + """Reverse migration - not needed since we're changing field types""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0068_transmitterreliability"), + ] + + operations = [ + # Step 1: Add a temporary ForeignKey field + migrations.AddField( + model_name='transmitters', + name='reliability_new', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="transmitters_temp", + to="hadis.transmitterreliability", + verbose_name="reliability", + null=True, + ), + ), + # Step 2: Run data migration to populate the new field + migrations.RunPython(migrate_transmitter_data, reverse_migrate), + # Step 3: Remove the old field + migrations.RemoveField( + model_name='transmitters', + name='reliability', + ), + # Step 4: Rename the new field to the final name + migrations.RenameField( + model_name='transmitters', + old_name='reliability_new', + new_name='reliability', + ), + ] diff --git a/apps/hadis/migrations/0070_alter_transmitters_reliability.py b/apps/hadis/migrations/0070_alter_transmitters_reliability.py new file mode 100644 index 0000000..da1e95d --- /dev/null +++ b/apps/hadis/migrations/0070_alter_transmitters_reliability.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.27 on 2025-12-23 08:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0069_alter_transmitters_reliability"), + ] + + operations = [ + migrations.AlterField( + model_name="transmitters", + name="reliability", + field=models.ForeignKey( + default=12, + on_delete=django.db.models.deletion.CASCADE, + related_name="transmitters", + to="hadis.transmitterreliability", + verbose_name="reliability", + ), + ), + ] diff --git a/apps/hadis/migrations/0070_migrate_transmitter_reliability_data.py b/apps/hadis/migrations/0070_migrate_transmitter_reliability_data.py new file mode 100644 index 0000000..0f990d6 --- /dev/null +++ b/apps/hadis/migrations/0070_migrate_transmitter_reliability_data.py @@ -0,0 +1,109 @@ +# Migration to handle data conversion for transmitter reliability field +from django.db import migrations + + +def create_reliability_objects(apps, schema_editor): + """Create TransmitterReliability objects for each reliability level""" + TransmitterReliability = apps.get_model('hadis', 'TransmitterReliability') + + # Define the reliability levels with their data + reliability_data = [ + { + 'title': [ + {'text': 'Very Reliable', 'language_code': 'en'}, + {'text': 'بسیار قابل اعتماد', 'language_code': 'fa'}, + {'text': 'Очень надежный', 'language_code': 'ru'} + ], + 'color': 'green', + 'value': 'very_reliable' + }, + { + 'title': [ + {'text': 'Reliable', 'language_code': 'en'}, + {'text': 'قابل اعتماد', 'language_code': 'fa'}, + {'text': 'Надежный', 'language_code': 'ru'} + ], + 'color': 'blue', + 'value': 'reliable' + }, + { + 'title': [ + {'text': 'Acceptable', 'language_code': 'en'}, + {'text': 'قابل قبول', 'language_code': 'fa'}, + {'text': 'Приемлемый', 'language_code': 'ru'} + ], + 'color': 'yellow', + 'value': 'acceptable' + }, + { + 'title': [ + {'text': 'Weak', 'language_code': 'en'}, + {'text': 'ضعیف', 'language_code': 'fa'}, + {'text': 'Слабый', 'language_code': 'ru'} + ], + 'color': 'orange', + 'value': 'weak' + }, + { + 'title': [ + {'text': 'Very Weak', 'language_code': 'en'}, + {'text': 'بسیار ضعیف', 'language_code': 'fa'}, + {'text': 'Очень слабый', 'language_code': 'ru'} + ], + 'color': 'red', + 'value': 'very_weak' + }, + { + 'title': [ + {'text': 'Unknown', 'language_code': 'en'}, + {'text': 'نامشخص', 'language_code': 'fa'}, + {'text': 'Неизвестный', 'language_code': 'ru'} + ], + 'color': 'gray', + 'value': 'unknown' + } + ] + + reliability_objects = {} + for data in reliability_data: + obj = TransmitterReliability.objects.create( + title=data['title'], + color=data['color'] + ) + reliability_objects[data['value']] = obj + + return reliability_objects + + +def migrate_transmitter_data(apps, schema_editor): + """Migrate existing transmitter reliability data""" + Transmitters = apps.get_model('hadis', 'Transmitters') + + # Create reliability objects + reliability_objects = create_reliability_objects(apps, schema_editor) + + # Update all transmitters to use the new foreign key references + for transmitter in Transmitters.objects.all(): + old_value = getattr(transmitter, 'reliability', None) + if old_value and old_value in reliability_objects: + transmitter.reliability = reliability_objects[old_value] + transmitter.save(update_fields=['reliability']) + elif old_value == 'unknown' or not old_value: + # Default to unknown if no value or unknown + transmitter.reliability = reliability_objects.get('unknown') + transmitter.save(update_fields=['reliability']) + + +def reverse_migrate(apps, schema_editor): + """Reverse migration - not needed since we're changing field types""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('hadis', '0069_alter_transmitters_reliability'), + ] + + operations = [ + migrations.RunPython(migrate_transmitter_data, reverse_migrate), + ] diff --git a/apps/hadis/migrations/0071_merge_20251223_1055.py b/apps/hadis/migrations/0071_merge_20251223_1055.py new file mode 100644 index 0000000..1aeb506 --- /dev/null +++ b/apps/hadis/migrations/0071_merge_20251223_1055.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.27 on 2025-12-23 10:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0070_alter_transmitters_reliability"), + ("hadis", "0070_migrate_transmitter_reliability_data"), + ] + + operations = [] diff --git a/apps/hadis/migrations/0072_transmitterreliability_slug.py b/apps/hadis/migrations/0072_transmitterreliability_slug.py new file mode 100644 index 0000000..ce0b4e6 --- /dev/null +++ b/apps/hadis/migrations/0072_transmitterreliability_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-23 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0071_merge_20251223_1055"), + ] + + operations = [ + migrations.AddField( + model_name="transmitterreliability", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0073_hadisstatus_slug.py b/apps/hadis/migrations/0073_hadisstatus_slug.py new file mode 100644 index 0000000..c615bac --- /dev/null +++ b/apps/hadis/migrations/0073_hadisstatus_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-23 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0072_transmitterreliability_slug"), + ] + + operations = [ + migrations.AddField( + model_name="hadisstatus", + name="slug", + field=models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0074_alter_hadisstatus_slug.py b/apps/hadis/migrations/0074_alter_hadisstatus_slug.py new file mode 100644 index 0000000..97961ae --- /dev/null +++ b/apps/hadis/migrations/0074_alter_hadisstatus_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2025-12-23 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0073_hadisstatus_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="hadisstatus", + name="slug", + field=models.SlugField( + blank=True, max_length=255, unique=True, verbose_name="slug" + ), + ), + ] diff --git a/apps/hadis/migrations/0075_opinionstatus.py b/apps/hadis/migrations/0075_opinionstatus.py new file mode 100644 index 0000000..57e4dd4 --- /dev/null +++ b/apps/hadis/migrations/0075_opinionstatus.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.27 on 2025-12-23 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0074_alter_hadisstatus_slug"), + ] + + operations = [ + migrations.CreateModel( + name="OpinionStatus", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.JSONField(default=list, verbose_name="Title")), + ( + "slug", + models.SlugField( + blank=True, max_length=255, null=True, verbose_name="slug" + ), + ), + ( + "color", + models.CharField( + choices=[ + ("red", "Red"), + ("green", "Green"), + ("blue", "Blue"), + ("yellow", "Yellow"), + ("orange", "Orange"), + ("purple", "Purple"), + ("gray", "Gray"), + ], + max_length=20, + verbose_name="color", + ), + ), + ], + options={ + "verbose_name": "transmitter reliability", + "verbose_name_plural": "transmitter reliability", + }, + ), + ] diff --git a/apps/hadis/migrations/0076_alter_transmitteropinion_status.py b/apps/hadis/migrations/0076_alter_transmitteropinion_status.py new file mode 100644 index 0000000..8e87b1a --- /dev/null +++ b/apps/hadis/migrations/0076_alter_transmitteropinion_status.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2025-12-23 13:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0075_opinionstatus"), + ] + + operations = [ + migrations.AlterField( + model_name="transmitteropinion", + name="status", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="opinions", + to="hadis.opinionstatus", + verbose_name="opinion status", + ), + ), + ] diff --git a/apps/hadis/migrations/0077_alter_transmitteropinion_status.py b/apps/hadis/migrations/0077_alter_transmitteropinion_status.py new file mode 100644 index 0000000..ccbaec0 --- /dev/null +++ b/apps/hadis/migrations/0077_alter_transmitteropinion_status.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.27 on 2025-12-23 14:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0076_alter_transmitteropinion_status"), + ] + + operations = [ + migrations.AlterField( + model_name="transmitteropinion", + name="status", + field=models.ForeignKey( + blank=True, + default=1, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="opinions", + to="hadis.opinionstatus", + verbose_name="opinion status", + ), + ), + ] diff --git a/apps/hadis/migrations/0078_alter_transmitteropinion_status.py b/apps/hadis/migrations/0078_alter_transmitteropinion_status.py new file mode 100644 index 0000000..8052de2 --- /dev/null +++ b/apps/hadis/migrations/0078_alter_transmitteropinion_status.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2025-12-23 14:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0077_alter_transmitteropinion_status"), + ] + + operations = [ + migrations.AlterField( + model_name="transmitteropinion", + name="status", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="opinions", + to="hadis.opinionstatus", + verbose_name="opinion status", + ), + ), + ] diff --git a/apps/hadis/migrations/0079_remove_transmitteropinion_status.py b/apps/hadis/migrations/0079_remove_transmitteropinion_status.py new file mode 100644 index 0000000..49222e9 --- /dev/null +++ b/apps/hadis/migrations/0079_remove_transmitteropinion_status.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.27 on 2025-12-23 14:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0078_alter_transmitteropinion_status"), + ] + + operations = [ + migrations.RemoveField( + model_name="transmitteropinion", + name="status", + ), + ] diff --git a/apps/hadis/migrations/0080_transmitteropinion_status.py b/apps/hadis/migrations/0080_transmitteropinion_status.py new file mode 100644 index 0000000..db31e5b --- /dev/null +++ b/apps/hadis/migrations/0080_transmitteropinion_status.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2025-12-23 14:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0079_remove_transmitteropinion_status"), + ] + + operations = [ + migrations.AddField( + model_name="transmitteropinion", + name="status", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="opinions", + to="hadis.opinionstatus", + verbose_name="opinion status", + ), + ), + ] diff --git a/apps/hadis/migrations/0081_alter_hadistransmitter_status.py b/apps/hadis/migrations/0081_alter_hadistransmitter_status.py new file mode 100644 index 0000000..b36a679 --- /dev/null +++ b/apps/hadis/migrations/0081_alter_hadistransmitter_status.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.27 on 2025-12-24 11:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0080_transmitteropinion_status"), + ] + + operations = [ + migrations.AlterField( + model_name="hadistransmitter", + name="status", + field=models.ForeignKey( + blank=True, + help_text="Reliability status of the narrator", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hadis_transmitters", + to="hadis.transmitterreliability", + verbose_name="reliability status", + ), + ), + ] diff --git a/apps/hadis/migrations/0082_remove_hadistransmitter_status.py b/apps/hadis/migrations/0082_remove_hadistransmitter_status.py new file mode 100644 index 0000000..effe156 --- /dev/null +++ b/apps/hadis/migrations/0082_remove_hadistransmitter_status.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.27 on 2025-12-24 12:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0081_alter_hadistransmitter_status"), + ] + + operations = [ + migrations.RemoveField( + model_name="hadistransmitter", + name="status", + ), + ] diff --git a/apps/hadis/migrations/0083_auto_20251224_1214.py b/apps/hadis/migrations/0083_auto_20251224_1214.py new file mode 100644 index 0000000..1daea60 --- /dev/null +++ b/apps/hadis/migrations/0083_auto_20251224_1214.py @@ -0,0 +1,15 @@ +from django.db import migrations + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0082_remove_hadistransmitter_status'), # Keep your actual dependency here + ] + + operations = [ + # This tells Django: "Run this SQL command, but don't try to update your internal model state" + migrations.RunSQL( + sql="ALTER TABLE hadis_hadistransmitter DROP COLUMN status;", + reverse_sql="ALTER TABLE hadis_hadistransmitter ADD COLUMN status varchar(255);" # Optional fallback + ), + ] \ No newline at end of file diff --git a/apps/hadis/migrations/0084_hadistransmitter_status.py b/apps/hadis/migrations/0084_hadistransmitter_status.py new file mode 100644 index 0000000..4f7755b --- /dev/null +++ b/apps/hadis/migrations/0084_hadistransmitter_status.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.27 on 2025-12-24 12:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0083_auto_20251224_1214"), + ] + + operations = [ + migrations.AddField( + model_name="hadistransmitter", + name="status", + field=models.ForeignKey( + blank=True, + help_text="Reliability status of the narrator", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hadis_transmitters", + to="hadis.transmitterreliability", + verbose_name="reliability status", + ), + ), + ] diff --git a/apps/hadis/migrations/0085_hadistransmitter_hadis_hadis_hadis_i_d04e3a_idx_and_more.py b/apps/hadis/migrations/0085_hadistransmitter_hadis_hadis_hadis_i_d04e3a_idx_and_more.py new file mode 100644 index 0000000..bde1c87 --- /dev/null +++ b/apps/hadis/migrations/0085_hadistransmitter_hadis_hadis_hadis_i_d04e3a_idx_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.27 on 2025-12-24 14:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0084_hadistransmitter_status"), + ] + + operations = [ + migrations.AddIndex( + model_name="hadistransmitter", + index=models.Index( + fields=["hadis", "order"], name="hadis_hadis_hadis_i_d04e3a_idx" + ), + ), + migrations.AddIndex( + model_name="referenceimage", + index=models.Index( + fields=["reference", "priority"], name="hadis_refer_referen_a37840_idx" + ), + ), + ] diff --git a/apps/hadis/migrations/0086_alter_opinionstatus_options_and_more.py b/apps/hadis/migrations/0086_alter_opinionstatus_options_and_more.py new file mode 100644 index 0000000..454b9d7 --- /dev/null +++ b/apps/hadis/migrations/0086_alter_opinionstatus_options_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.27 on 2025-12-27 09:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0085_hadistransmitter_hadis_hadis_hadis_i_d04e3a_idx_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="opinionstatus", + options={ + "verbose_name": "Opinion Status", + "verbose_name_plural": "Opinion Statuses", + }, + ), + migrations.AlterModelOptions( + name="transmitterreliability", + options={ + "verbose_name": "Transmitter Reliability", + "verbose_name_plural": "Transmitter Reliabilities", + }, + ), + ] diff --git a/apps/hadis/migrations/0087_contentrelease.py b/apps/hadis/migrations/0087_contentrelease.py new file mode 100644 index 0000000..717ef51 --- /dev/null +++ b/apps/hadis/migrations/0087_contentrelease.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.27 on 2025-12-27 10:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hadis", "0086_alter_opinionstatus_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ContentRelease", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "version_name", + models.CharField(max_length=50, verbose_name="Version Name"), + ), + ( + "published_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Published Date" + ), + ), + ( + "description", + models.TextField(blank=True, verbose_name="Release Description"), + ), + ("is_active", models.BooleanField(default=True, verbose_name="Active")), + ], + options={ + "ordering": ["-published_at"], + }, + ), + ] diff --git a/apps/hadis/models/__init__.py b/apps/hadis/models/__init__.py index ba8ce70..7074a60 100644 --- a/apps/hadis/models/__init__.py +++ b/apps/hadis/models/__init__.py @@ -1,3 +1,9 @@ from .category import * from .hadis import * -from .transmitter import * \ No newline at end of file +<<<<<<< HEAD +from .transmitter import * +======= +from .transmitter import * +from .reference import * +from .version import * +>>>>>>> develop diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py index 7fde53e..26b05da 100644 --- a/apps/hadis/models/category.py +++ b/apps/hadis/models/category.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError +<<<<<<< HEAD from dj_category.models import BaseCategoryAbstract @@ -23,19 +24,128 @@ class HadisCategory(BaseCategoryAbstract): name = models.CharField(max_length=355, verbose_name=_('name')) order = models.IntegerField(default=0, verbose_name=_('order')) slug = None +======= +from mptt.models import MPTTModel, TreeForeignKey +from django.utils.text import slugify + + +class HadisSect(models.Model): + class SectType(models.TextChoices): + SHIA = 'shia', _('Shia') + SUNNI = 'sunni', _('Sunni') + + sect_type = models.CharField(max_length=10, choices=SectType.choices, unique=True, verbose_name=_('Sect Name')) + title = models.JSONField(default = list , verbose_name=_('Title')) + description = models.JSONField(default = list , verbose_name=_('Description')) + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) + order = models.IntegerField(default=0, verbose_name=_('order')) + + def __str__(self): + return f"{self.sect_type}: {self.title[0]['text']}" + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + + + class Meta: + verbose_name = _('Hadis Sect') + verbose_name_plural = _('Hadis Sects') + ordering = ('order',) + + +class HadisCategory(MPTTModel): + class SourceType(models.TextChoices): + QURAN = 'quran', _('Quran') + HADITH = 'hadith', _('Hadith') + HISTORY = 'history', _('History') + FATWA = 'fatwa', _('Fatwa') + QUOTE = 'quote', _('Quote') + + parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') + sect = models.ForeignKey(HadisSect, on_delete=models.PROTECT, verbose_name=_('Sect'), null=False, blank=False) + source_type = models.CharField(max_length=10, choices=SourceType.choices, verbose_name=_('Source Type')) + title = models.JSONField(default = list , verbose_name=_('Title')) + description = models.JSONField(default = list , verbose_name=_('Description')) + order = models.IntegerField(default=0, verbose_name=_('order')) + xmind_file = models.FileField(upload_to='hadis/xmind_files/', verbose_name=_('xmind file'), null=True, blank=True) + slug = models.SlugField(max_length=255, null=True, blank=True) +>>>>>>> develop content_type = None language = None language_id = None +<<<<<<< HEAD # This field is not stored in the database, it's only used for the form level_choice = None class Meta: +======= + def clean(self): + super().clean() + if self.parent and self.sect_id != self.parent.sect_id: + raise ValidationError( + _('Child category must have the same sect_type as its parent. ' + f'Parent sect: {self.parent.sect.sect_type}, ' + f'Your sect: {self.sect.sect_type}') + ) + + def save(self, *args, **kwargs): + self.full_clean() + if not self.slug: + title = self.title[0]['text'] + base_slug = slugify(title, allow_unicode=True) + slug = base_slug + while HadisCategory.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}" + self.slug = slug + super().save(*args, **kwargs) + + + + class Meta: + indexes = [ + models.Index(fields=['parent', 'sect']), + models.Index(fields=['sect', 'order']) + ] +>>>>>>> develop verbose_name = _('Hadis Category') verbose_name_plural = _('Hadis Categories') ordering = ('order',) def __str__(self): +<<<<<<< HEAD return f'<{str(self.level_p)}>{self.name}' def __repr__(self): @@ -103,3 +213,42 @@ class HadisCategory(BaseCategoryAbstract): +======= + return f"{self.sect.sect_type}: {self.source_type} - {self.title[0]['text']}" + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + +>>>>>>> develop diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index cbe94d9..36d692b 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from django.db import models @@ -12,11 +13,184 @@ class HadisTag(models.Model): def __str__(self): return f"{self.title}" +======= +from enum import unique +from typing import Optional +from django.db import models +from django.db.models import F, ForeignKey +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.utils.text import slugify +from filer.fields.image import FilerImageField +from .reference import BookReference +from utils.slug import generate_smart_slug + +class HadisCollection(models.Model): + title = models.JSONField(default = list , verbose_name=_('Title')) + slug = models.SlugField(max_length=255, unique=True, verbose_name=_('slug'), blank=True) + summary = models.JSONField(default = list , verbose_name=_('Summary')) + status = models.BooleanField(default=True, verbose_name=_('status')) + order = models.IntegerField(default=0, verbose_name=_('order')) + thumbnail = FilerImageField( + related_name="+", + on_delete=models.CASCADE, + help_text=_('thumbnail image'), + null=True, + blank=True, + verbose_name=_('thumbnail') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title[0]['text'], allow_unicode=True) + slug = base_slug + counter = 1 + while HadisCollection.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def __str__(self): + return self.title[0]['text'] + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_summary(self,lang): + """ + Get translation for a specific language + """ + + if not self.summary or not isinstance(self.summary, list): + return None + + for tr in self.summary: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.summary: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + class Meta: + verbose_name = _('hadis collection') + verbose_name_plural = _('hadis collections') + ordering = ('order',) + + +class HadisInCollection(models.Model): + hadis = models.ForeignKey('Hadis', on_delete=models.CASCADE, verbose_name=_('hadis'), related_name='collection_items') + collection = models.ForeignKey(HadisCollection, on_delete=models.CASCADE, verbose_name=_('collection'), related_name='hadis_items') + order = models.IntegerField(default=0, verbose_name=_('order')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + class Meta: + verbose_name = _('hadis in collection') + verbose_name_plural = _('hadis in collections') + ordering = ('order',) + unique_together = ('hadis', 'collection') + + def __str__(self): + return f"{self.collection.title[0]['text']} - {self.hadis.number}" + + +class HadisTag(models.Model): + title = models.JSONField(default = list , verbose_name=_('Title')) + 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 f"{self.title[0]['text']}" + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + +class HadisStatus(models.Model): + class ColorChoices(models.TextChoices): + RED = 'red', _('Red') + GREEN = 'green', _('Green') + BLUE = 'blue', _('Blue') + YELLOW = 'yellow', _('Yellow') + ORANGE = 'orange', _('Orange') + PURPLE = 'purple', _('Purple') + GRAY = 'gray', _('Gray') + + title = models.JSONField(default = list , verbose_name=_('Title')) + slug= models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique = True) + color = models.CharField(max_length=20, choices=ColorChoices.choices, verbose_name=_('color')) + order = models.IntegerField(default=0, verbose_name=_('order')) + + def save(self, *args, **kwargs): + if not self.slug: + slug = slugify(self.title[0]['text']) + self.slug = slug + super().save(*args, **kwargs) + + def __str__(self): + return self.title[0]['text'] + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + class Meta: + verbose_name = _('hadis status') + verbose_name_plural = _('hadis statuses') + ordering = ('order',) +>>>>>>> develop class Hadis(models.Model): +<<<<<<< HEAD number = models.PositiveIntegerField(verbose_name=_('number'), unique=True) title = models.CharField(max_length=355, verbose_name=_('title')) text = models.TextField(verbose_name=_('text')) @@ -25,10 +199,33 @@ class Hadis(models.Model): category = models.ForeignKey("hadis.HadisCategory", null=True, on_delete=models.SET_NULL, verbose_name=_('category'), ) status = models.BooleanField(default=True, verbose_name=_('visibility')) +======= + category = models.ForeignKey("hadis.HadisCategory", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('category')) + number = models.PositiveIntegerField(verbose_name=_('number'), default=1) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) + title_narrator = models.JSONField(default = list , verbose_name=_('Title Narrator')) + title = models.JSONField(default = list , verbose_name=_('Title')) + description = models.JSONField(default = list , verbose_name=_('Description')) + + text = models.TextField(verbose_name=_('text')) + translation = models.JSONField(verbose_name=_('translation'), default=list) + status = models.BooleanField(default=True, verbose_name=_('visibility')) + + hadis_status = models.ForeignKey(HadisStatus, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('hadis status')) + hadis_status_text = models.JSONField(default = list , verbose_name=_('Status text')) + address = models.JSONField(default = list , verbose_name=_('Address')) + links = models.JSONField(verbose_name=_('links'), null=True, blank=True, default=dict) + tags = models.ManyToManyField("HadisTag", related_name="hadis_overview", verbose_name=_('tags'), blank=True) + + share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) + explanation = models.JSONField(default = list , verbose_name=_('Explanation')) + +>>>>>>> develop 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): +<<<<<<< HEAD return f"<{self.number}> {self.title[:32]}" @property @@ -53,6 +250,120 @@ class HadisOverview(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) +======= + return f"{self.number} - {self.title[0]['text']}" if self.title else f"Hadis {self.number}" + + def save(self, *args, **kwargs): + """ + Override save to automatically generate smart slugs. + + - If slug is empty, generates a new one + - Uses the first 8 words from the title (configurable) + - Ensures uniqueness with counters (-1, -2, etc.) + - Max length: 100 characters (configurable) + + Examples: + >>> hadis = Hadis.objects.create( + ... number=1877, + ... title=[ + ... { + ... "text": "Fatwa on Combining Prayers While Traveling and Missing Prayer", + ... "language_code": "en" + ... } + ... ] + ... ) + >>> print(hadis.slug) + 'fatwa-on-combining-prayers-while' + """ + + # Generate slug if not already set + if not self.slug and self.title: + # Extract title text + title_text = None + if isinstance(self.title, list) and self.title: + first_item = self.title[0] + if isinstance(first_item, dict): + title_text = first_item.get("text") + + # Generate smart slug + if title_text: + self.slug = generate_smart_slug( + text=title_text, + model_class=Hadis, + max_length=100, # ← Adjust max length here + keep_words=8, # ← Limit to 8 words (your requirement) + instance=self, + ) + else: + # Fallback if title is empty + self.slug = f"hadis-{self.number or 'unknown'}" + + # Call parent save + super().save(*args, **kwargs) + + def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): + """ + Generic getter for JSONField in our [{text, language_code}] format. + Usage: self._get_json_field('title', 'fa') + """ + if lang is None: + lang = fallback + + value = getattr(self, field_name, None) + if not value or not isinstance(value, list): + return None + + # 1) exact language + for item in value: + if isinstance(item, dict) and item.get("language_code") == lang: + return item.get("text", "") + + # 2) fallback language + if fallback and fallback != lang: + for item in value: + if isinstance(item, dict) and item.get("language_code") == fallback: + return item.get("text", "") + + # 3) first available + item = value[0] + print(item) + return item.get("text", "") if isinstance(item, dict) else None + + def get_translation(self, lang): + return self._get_json_field("translation" , lang) + + def get_title(self,lang): + return self._get_json_field("title" , lang) + + def get_description(self, lang): + return self._get_json_field("description" , lang) + + def get_hadis_status_text(self, lang): + return self._get_json_field("hadis_status_text" , lang) + + def get_address(self, lang): + return self._get_json_field("address" , lang) + + def get_explanation(self, lang): + return self._get_json_field("explanation" , lang) + + def save(self, *args, **kwargs): + # ساخت share_link قبل از ذخیره + if not self.share_link: + self.share_link = f"{settings.SITE_DOMAIN}/hadis/{self.slug}" + super().save(*args, **kwargs) + + class Meta: + indexes = [ + # Optimizes: Hadis.objects.filter(status=True).order_by('id') + models.Index(fields=['status', 'id']), + ] + verbose_name = _('hadis') + verbose_name_plural = _('hadises') + ordering = ('category', 'number') + + +>>>>>>> develop class HadisReference(models.Model): hadis = models.ForeignKey( @@ -61,6 +372,7 @@ class HadisReference(models.Model): verbose_name=_('hadis'), related_name='references' ) +<<<<<<< HEAD book = models.ForeignKey("library.Book", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('book'), related_name='hadis_references') description = models.TextField(verbose_name=_('description'), blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) @@ -79,6 +391,52 @@ class ReferenceImage(models.Model): related_name='+', on_delete=models.PROTECT, null=True, blank=True, verbose_name=_('thumbnail') ) +======= + book_reference = models.ForeignKey( + BookReference, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('book reference'), + related_name='hadis_references' + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + description = models.JSONField(default = list , verbose_name=_('Description')) + + class Meta: + indexes = [ + # For fetching hadises by book + models.Index(fields=['book_reference']), + ] + verbose_name = _('Hadis Reference') + verbose_name_plural = _('Hadis References') + # unique_together = ('hadis', 'book_reference') + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def __str__(self): + return f'{self.hadis.number}-{self.book_reference.title[0]["text"] if self.book_reference else "No Book Reference"}' + +class ReferenceImage(models.Model): + reference = models.ForeignKey(HadisReference,related_name = 'images', verbose_name="Hadis Reference", on_delete=models.CASCADE) + thumbnail = models.ImageField(upload_to='hadis/reference_images/', null=True, blank=True, verbose_name=_('thumbnail')) +>>>>>>> develop priority = models.IntegerField( default=0, verbose_name=_("Priority"), @@ -87,11 +445,22 @@ class ReferenceImage(models.Model): class Meta: +<<<<<<< HEAD +======= + indexes = [ + # Speeds up fetching images for a reference in priority order + models.Index(fields=['reference', 'priority']), + ] +>>>>>>> develop verbose_name = _('Reference Image') verbose_name_plural = _('Reference Images') def __str__(self): +<<<<<<< HEAD return f'{self.reference.title}-{self.id}' +======= + return f'{self.reference.title[0]["text"]}-{self.id}' +>>>>>>> develop def save(self, *args, **kwargs): if ReferenceImage.objects.filter(reference=self.reference, priority=self.priority).exists(): @@ -101,4 +470,108 @@ class ReferenceImage(models.Model): ).update(priority=F('priority') + 1) super().save(*args, **kwargs) +<<<<<<< HEAD + +======= + +class HadisCorrection(models.Model): + hadis = models.ForeignKey(Hadis, verbose_name=_("hadis correction"), on_delete=models.CASCADE) + title = models.JSONField(default = list , verbose_name=_('Title')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) + description =models.JSONField(default = list , verbose_name=_('Description')) + translation = models.JSONField(verbose_name=_("translation"), default=list) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("updated at")) + share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) + + class Meta: + verbose_name = _("Hadis Correction") + verbose_name_plural = _("Hadis Corrections") + ordering = ("-created_at",) + + def __str__(self): + return f"{self.hadis.number} - {self.title[0]['text']}" + + def save(self, *args, **kwargs): + """ + Override save to automatically generate smart slugs. + """ + # Generate slug if not already set + if not self.slug and self.title: + # Extract title text + title_text = None + if isinstance(self.title, list) and self.title: + first_item = self.title[0] + if isinstance(first_item, dict): + title_text = first_item.get("text") + + # Generate smart slug + if title_text: + self.slug = generate_smart_slug( + text=title_text, + model_class=HadisCorrection, + max_length=100, # ← Adjust max length here + keep_words=8, # ← Limit to 8 words (your requirement) + instance=self, + ) + else: + # Fallback if title is empty + self.slug = f"correction-{self.hadis.slug}-{self.id}" + + # Call parent save + if not self.share_link: + self.share_link = f"{settings.SITE_DOMAIN}/hadis/{self.hadis.slug}/corrections/{self.slug}" + super().save(*args, **kwargs) + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_translation(self,lang): + """ + Get title for a specific language + """ + + if not self.translation or not isinstance(self.translation, list): + return None + + for tr in self.translation: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.translation: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None +>>>>>>> develop diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py new file mode 100644 index 0000000..b8912c7 --- /dev/null +++ b/apps/hadis/models/reference.py @@ -0,0 +1,237 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.text import slugify +from typing import Optional + + +class BookReference(models.Model): + """ + Model for hadis book references with detailed information + This is different from library books - these are reference books for hadis + """ + title = models.JSONField(default = list , verbose_name=_('Title')) + description = models.JSONField(default = list , verbose_name=_('Description')) + language = models.JSONField(default = list , verbose_name=_('Language')) + isbn = models.CharField(max_length=100, verbose_name=_('ISBN'), blank=True, null=True) + volume = models.CharField(max_length=100, verbose_name=_('volume'), blank=True, null=True) + year_of_publication = models.CharField(max_length=50, verbose_name=_('year of publication'), blank=True, null=True) + number_page = models.PositiveIntegerField(verbose_name=_('number of pages'), blank=True, null=True) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) + publisher = models.JSONField(default = list , verbose_name=_('Publisher')) + rate = models.DecimalField( + max_digits=3, + decimal_places=2, + verbose_name=_('rate'), + blank=True, + null=True, + help_text=_('Rating from 0 to 5') + ) + + 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: + indexes = [ + models.Index(fields=['id']), # Already indexed (PK) + ] + verbose_name = _('Book Reference') + verbose_name_plural = _('Book References') + ordering = ('-created_at',) + + def __str__(self): + return self.title[0]['text'] + + def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): + """ + Generic getter for JSONField in our [{text, language_code}] format. + Usage: self._get_json_field('title', 'fa') + """ + if lang is None: + lang = fallback + + value = getattr(self, field_name, None) + if not value or not isinstance(value, list): + return None + + # 1) exact language + for item in value: + if isinstance(item, dict) and item.get("language_code") == lang: + return item.get("text", "") + + # 2) fallback language + if fallback and fallback != lang: + for item in value: + if isinstance(item, dict) and item.get("language_code") == fallback: + return item.get("text", "") + + # 3) first available + item = value[0] + print(item) + return item.get("text", "") if isinstance(item, dict) else None + + def get_title(self,lang): + return self._get_json_field("title" , lang) + + def get_description(self, lang): + return self._get_json_field("description" , lang) + + + def get_publisher(self, lang): + return self._get_json_field("publisher" , lang) + + def get_language(self, lang): + return self._get_json_field("language" , lang) + + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title[0]['text'], allow_unicode=True) + slug = base_slug + counter = 1 + while BookReference.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + +class BookReferenceImage(models.Model): + """ + Model for book reference images - multiple images per book reference + """ + book_reference = models.ForeignKey( + BookReference, + on_delete=models.CASCADE, + related_name='images', + verbose_name=_('book reference') + ) + image = models.ImageField(upload_to='hadis/book_reference_images/', verbose_name=_('image')) + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + description = models.JSONField(default = list , verbose_name=_('Description')) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + class Meta: + verbose_name = _('Book Reference Image') + verbose_name_plural = _('Book Reference Images') + ordering = ['order', '-created_at'] + + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def __str__(self): + return f"{self.book_reference.title[0]['text']} - Image {self.order}" + + +class BookAuthor(models.Model): + """ + Model for book reference authors + """ + name = models.JSONField(default = list , verbose_name=_('Name')) + book_references = models.ManyToManyField( + BookReference, + related_name='authors', + verbose_name=_('book references'), + blank=True + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Book Author') + verbose_name_plural = _('Book Authors') + ordering = ['name'] + + def __str__(self): + return self.name[0]['text'] + + def get_name(self,lang): + """ + Get title for a specific language + """ + + if not self.name or not isinstance(self.name, list): + return None + + for tr in self.name: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.name: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + +class BookAttribute(models.Model): + """ + Model for book reference attributes - custom key-value pairs + """ + title = models.JSONField(default = list , verbose_name=_('Title')) + value = models.JSONField(default = list , verbose_name=_('Value')) + book_reference = models.ForeignKey( + BookReference, + on_delete=models.CASCADE, + related_name='attributes', + verbose_name=_('book attribute'), + default = None, + null = True + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Book Attribute') + verbose_name_plural = _('Book Attributes') + ordering = ['title'] + + def __str__(self): + return f"{self.title[0]['text']}: {self.value[0]['text']}" + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_value(self,lang): + """ + Get title for a specific language + """ + + if not self.value or not isinstance(self.value, list): + return None + + for tr in self.value: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.value: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 5ec2e0b..6159ae6 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -1,13 +1,133 @@ +<<<<<<< HEAD from django.db import models from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from filer.fields.image import FilerImageField +======= +from tabnanny import verbose +from django.db import models +from django.utils.translation import gettext_lazy as _ +from filer.fields.image import FilerImageField +from django.utils.text import slugify +from typing import Optional +from utils.slug import generate_smart_slug +from django.conf import settings + + + +class NarratorLayer(models.Model): + """ + Model for narrator layers/classes (Tabaqat) + Represents the classification level of narrators in hadis chains + """ + name = models.JSONField(default = list , verbose_name=_('Name')) + number = models.PositiveIntegerField(verbose_name=_('layer number'), unique=True) + description = models.JSONField(default = list , verbose_name=_('Description')) + slug = models.SlugField(max_length=255,unique=True, verbose_name=_('slug'), blank=True) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Narrator Layer') + verbose_name_plural = _('Narrator Layers') + ordering = ['number'] + + def __str__(self): + return f"{_('Layer')} {self.number} - {self.name[0]['text']}" + + def get_description(self,lang): + """ + Get title for a specific language + """ + + if not self.description or not isinstance(self.description, list): + return None + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.description: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_name(self,lang): + """ + Get title for a specific language + """ + + if not self.name or not isinstance(self.name, list): + return None + + for tr in self.name: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.name: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def save(self, *args, **kwargs): + if not self.slug: + slug = slugify(self.name[0]['text']) + self.slug = slug + super().save(*args, **kwargs) + +class TransmitterReliability(models.Model): + class ColorChoices(models.TextChoices): + RED = 'red', _('Red') + GREEN = 'green', _('Green') + BLUE = 'blue', _('Blue') + YELLOW = 'yellow', _('Yellow') + ORANGE = 'orange', _('Orange') + PURPLE = 'purple', _('Purple') + GRAY = 'gray', _('Gray') + + title = models.JSONField(default = list , verbose_name=_('Title')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,null=True) + + color = models.CharField(max_length=20, choices=ColorChoices.choices, verbose_name=_('color')) + + def save(self, *args, **kwargs): + if not self.slug: + slug = slugify(self.title[0]['text']) + self.slug = slug + super().save(*args, **kwargs) + + def __str__(self): + return self.title[0]['text'] + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + class Meta: + verbose_name = _('Transmitter Reliability') + verbose_name_plural = _('Transmitter Reliabilities') +>>>>>>> develop class Transmitters(models.Model): +<<<<<<< HEAD full_name = models.CharField(max_length=255) birth_year_hijri = models.IntegerField(verbose_name="Birth Year (Hijri)") death_year_hijri = models.IntegerField(verbose_name="Death Year (Hijri)") @@ -20,6 +140,158 @@ class Transmitters(models.Model): def __str__(self): return self.full_name +======= + # class ReliabilityLevel(models.TextChoices): + # VERY_RELIABLE = 'very_reliable', _('Very Reliable') + # RELIABLE = 'reliable', _('Reliable') + # ACCEPTABLE = 'acceptable', _('Acceptable') + # WEAK = 'weak', _('Weak') + # VERY_WEAK = 'very_weak', _('Very Weak') + # UNKNOWN = 'unknown', _('Unknown') + + class MadhhabChoices(models.TextChoices): + SHIA = 'shia', _('Shia') + SUNNI = 'sunni', _('Sunni') + HANAFI = 'hanafi', _('Hanafi') + MALIKI = 'maliki', _('Maliki') + SHAFII = 'shafii', _('Shafi\'i') + HANBALI = 'hanbali', _('Hanbali') + OTHER = 'other', _('Other') + UNKNOWN = 'unknown', _('Unknown') + + # Basic Information + full_name = models.JSONField(default = list , verbose_name=_('Full Name')) + kunya = models.JSONField(default = list , verbose_name=_('Kunya')) + known_as = models.JSONField(default = list , verbose_name=_('Known as')) + nickname = models.JSONField(default = list , verbose_name=_('Nick Name')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) + + # Geographic Information + origin = models.JSONField(default = list , verbose_name=_('Origin')) + lived_in = models.JSONField(default = list , verbose_name=_('Lived in')) + died_in = models.JSONField(default = list , verbose_name=_('Died in')) + + # Date Information + birth_year_hijri = models.IntegerField(verbose_name=_("Birth Year (Hijri)"), null=True, blank=True) + death_year_hijri = models.IntegerField(verbose_name=_("Death Year (Hijri)"), null=True, blank=True) + age_at_death = models.PositiveIntegerField(verbose_name=_('Age at Death'), blank=True, null=True) + generation = models.PositiveIntegerField(verbose_name=_('Generation'), blank=True, null=True) + # Religious & Academic Information + reliability = models.ForeignKey( + TransmitterReliability, + on_delete=models.CASCADE, + verbose_name=_('reliability'), + related_name='transmitters', + default=12 # ID of 'Unknown' reliability + ) + madhhab = models.CharField( + max_length=20, + choices=MadhhabChoices.choices, + default=MadhhabChoices.UNKNOWN, + verbose_name=_('Madhhab/School of Thought') + ) + + # Presence in Famous Collections + in_sahih_muslim = models.BooleanField( + default=False, + verbose_name=_('In Sahih Muslim'), + help_text=_('Is this narrator present in Sahih Muslim?') + ) + in_sahih_bukhari = models.BooleanField( + default=False, + verbose_name=_('In Sahih Bukhari'), + help_text=_('Is this narrator present in Sahih Bukhari?') + ) + + # Additional Information + description = models.JSONField(default = list , verbose_name=_('Description')) + thumbnail = FilerImageField( + related_name="+", + on_delete=models.CASCADE, + help_text=_('image allowed'), + null=True, + blank=True + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + indexes = [ + # For ordering in sync API + models.Index(fields=['id']), + ] + verbose_name = _('Transmitter') + verbose_name_plural = _('Transmitters') + ordering = ('full_name',) + + def save(self, *args, **kwargs): + if not self.slug: + name = self.full_name[0] + base_slug = slugify(name.get('text'), allow_unicode=True) + slug = base_slug + counter = 1 + while Transmitters.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): + """ + Generic getter for JSONField in our [{text, language_code}] format. + Usage: self._get_json_field('title', 'fa') + """ + if lang is None: + lang = fallback + + value = getattr(self, field_name, None) + if not value or not isinstance(value, list): + return None + + # 1) exact language + for item in value: + if isinstance(item, dict) and item.get("language_code") == lang: + return item.get("text", "") + + # 2) fallback language + if fallback and fallback != lang: + for item in value: + if isinstance(item, dict) and item.get("language_code") == fallback: + return item.get("text", "") + + # 3) first available + item = value[0] + print(item) + return item.get("text", "") if isinstance(item, dict) else None + + def get_full_name(self, lang): + return self._get_json_field("full_name" , lang) + + def get_kunya(self, lang): + return self._get_json_field("kunya" , lang) + + def get_nickname(self, lang): + return self._get_json_field("nickname" , lang) + + def get_origin(self, lang): + return self._get_json_field("origin" , lang) + + def get_lived_in(self,lang): + return self._get_json_field("lived_in" , lang) + + def get_died_in(self, lang): + return self._get_json_field("died_in" , lang) + + def get_description(self, lang): + return self._get_json_field("description" , lang) + + + def __str__(self): + name = self.full_name[0] + return name.get('text') + +>>>>>>> develop class HadisTransmitter(models.Model): @@ -35,19 +307,271 @@ class HadisTransmitter(models.Model): verbose_name=_('transmitter'), related_name='hadises' ) +<<<<<<< HEAD description = models.TextField(verbose_name=_('description'), blank=True, null=True) +======= + narrator_layer = models.ForeignKey( + NarratorLayer, + on_delete=models.SET_NULL, + verbose_name=_('narrator layer'), + related_name='transmitters', + null=True, + blank=True, + help_text=_('The layer/class (Tabaqah) this narrator belongs to') + ) + status = models.ForeignKey( + TransmitterReliability, + on_delete=models.SET_NULL, + verbose_name=_('reliability status'), + related_name='hadis_transmitters', + null=True, + blank=True, + help_text=_('Reliability status of the narrator') + ) +>>>>>>> develop order = models.PositiveIntegerField( default=0, verbose_name=_('Order'), help_text=_('Order in the chain of transmission') ) +<<<<<<< HEAD created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) class Meta: +======= + is_gap = models.BooleanField(default=False, verbose_name=_('is gap')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + + class Meta: + indexes = [ + # Speeds up fetching transmitters for a specific hadis in order + models.Index(fields=['hadis', 'order']), + ] +>>>>>>> develop verbose_name = _('Hadis Transmitter') verbose_name_plural = _('Hadis Transmitters') ordering = ('hadis', 'order') unique_together = ('hadis', 'transmitter', 'order') def __str__(self): +<<<<<<< HEAD return f'{self.hadis.number} - {self.transmitter.full_name} ({self.order})' +======= + layer_info = f" - {self.narrator_layer}" if self.narrator_layer else "" + return f'{self.hadis.number} - {self.transmitter.full_name} ({self.order}){layer_info}' + + +class OpinionStatus(models.Model): + class ColorChoices(models.TextChoices): + RED = 'red', _('Red') + GREEN = 'green', _('Green') + BLUE = 'blue', _('Blue') + YELLOW = 'yellow', _('Yellow') + ORANGE = 'orange', _('Orange') + PURPLE = 'purple', _('Purple') + GRAY = 'gray', _('Gray') + + title = models.JSONField(default = list , verbose_name=_('Title')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,null=True) + + color = models.CharField(max_length=20, choices=ColorChoices.choices, verbose_name=_('color')) + + def save(self, *args, **kwargs): + if not self.slug: + slug = slugify(self.title[0]['text']) + self.slug = slug + super().save(*args, **kwargs) + + def __str__(self): + return self.title[0]['text'] + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + class Meta: + verbose_name = _('Opinion Status') + verbose_name_plural = _('Opinion Statuses') + + +class TransmitterOpinion(models.Model): + """ + Model for scholarly opinions about transmitters + """ + # class OpinionStatus(models.TextChoices): + # CONFIRMED = 'confirmed', _('Confirmed') + # MIXED = 'mixed', _('Mixed') + # REJECTED = 'rejected', _('Rejected') + + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('transmitter'), + related_name='opinions' + ) + scholar_name = models.JSONField(default = list , verbose_name=_('Scholar Name')) + opinion_text = models.JSONField(default = list , verbose_name=_('Opinion Text')) + status = models.ForeignKey( + OpinionStatus, + on_delete=models.CASCADE, + verbose_name=_('opinion status'), + related_name='opinions', + # default=1, + blank = True, + null=True + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + indexes = [ + # For filtering by transmitter + ordering + models.Index(fields=['transmitter']), + ] + verbose_name = _('Transmitter Opinion') + verbose_name_plural = _('Transmitter Opinions') + ordering = ('-created_at',) + + def __str__(self): + return f"{self.scholar_name[0]['text']}'s opinion on {self.transmitter.full_name[0]['text']} ({self.status})" + + def get_scholar_name(self,lang): + """ + Get title for a specific language + """ + + if not self.scholar_name or not isinstance(self.scholar_name, list): + return None + + for tr in self.scholar_name: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.scholar_name: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_opinion_tex(self,lang): + """ + Get title for a specific language + """ + + if not self.opinion_text or not isinstance(self.opinion_text, list): + return None + + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + +class TransmitterOriginalText(models.Model): + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('transmitter'), + related_name='originaltexts' + ) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) + title = models.JSONField(default = list , verbose_name=_('Title')) + text = models.JSONField(default = list , verbose_name=_('Text')) + translation = models.JSONField(verbose_name=_('translation'), default=list) + share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) + + class Meta: + indexes = [ + # For filtering by transmitter + ordering + models.Index(fields=['transmitter']), + ] + verbose_name = _('Transmitter Original Text') + verbose_name_plural = _('Transmitter Original Text') + + def __str__(self): + return f"{self.title[0]['text']} by {self.transmitter.full_name[0]['text']}" + + def save(self, *args, **kwargs): + """ + Override save to automatically generate smart slugs. + """ + + # Generate slug if not already set + if not self.slug and self.title: + # Extract title text + title_text = None + if isinstance(self.title, list) and self.title: + first_item = self.title[0] + if isinstance(first_item, dict): + title_text = first_item.get("text") + + # Generate smart slug + if title_text: + self.slug = generate_smart_slug( + text=title_text, + model_class=TransmitterOriginalText, + max_length=100, # ← Adjust max length here + keep_words=8, # ← Limit to 8 words (your requirement) + instance=self, + ) + else: + # Fallback if title is empty + self.slug = f"original-text-{self.transmitter.slug}-{self.id or 'unknown'}" + + # Call parent save + if not self.share_link: + self.share_link = f"{settings.SITE_DOMAIN}/hadis/narrators/{self.transmitter.slug}/original-texts/{self.slug}" + super().save(*args, **kwargs) + + + def get_title(self,lang): + """ + Get title for a specific language + """ + + if not self.title or not isinstance(self.title, list): + return None + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None + + def get_opinion_tex(self,lang): + + """ + Get title for a specific language + """ + + if not self.opinion_text or not isinstance(self.opinion_text, list): + return None + + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') + + for tr in self.opinion_text: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') + return None +>>>>>>> develop diff --git a/apps/hadis/models/version.py b/apps/hadis/models/version.py new file mode 100644 index 0000000..09b92c0 --- /dev/null +++ b/apps/hadis/models/version.py @@ -0,0 +1,13 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +class ContentRelease(models.Model): + version_name = models.CharField(max_length=50, verbose_name=_('Version Name')) # e.g., "v1.2 - Muharram Update" + published_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Published Date")) + description = models.TextField(blank=True, verbose_name=_("Release Description")) + + # Optional: is_active to easily rollback updates if something breaks + is_active = models.BooleanField(default=True, verbose_name=_("Active")) + + class Meta: + ordering = ['-published_at'] \ No newline at end of file diff --git a/apps/hadis/serializers/__init__.py b/apps/hadis/serializers/__init__.py new file mode 100644 index 0000000..1f4ec91 --- /dev/null +++ b/apps/hadis/serializers/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +from .version import * \ No newline at end of file diff --git a/apps/hadis/serializers/category.py b/apps/hadis/serializers/category.py new file mode 100644 index 0000000..7d67a76 --- /dev/null +++ b/apps/hadis/serializers/category.py @@ -0,0 +1,207 @@ +from django.utils.translation import get_language +from rest_framework import serializers +from django.utils.translation import gettext_lazy as _ +# from django.db.models import Count + +from ..models import HadisSect, HadisCategory, Hadis , HadisCategory +from django.utils.translation import get_language + +def get_localized_text(json_list, request=None, fallback_lang="en"): + """ + Extract localized text from a JSON list based on request's language. + + Expects: [{"language_code": "en", "text": "..."}, ...] + Returns: Single text string or None + """ + if not json_list or not isinstance(json_list, list): + return None + + # Get target language + language_code = getattr(request, "LANGUAGE_CODE", None) if request else None + if not language_code: + language_code = get_language() or fallback_lang + + # 1) Exact match + for item in json_list: + if isinstance(item, dict) and item.get("language_code") == language_code: + return item.get("text") + + # 2) Fallback to English + for item in json_list: + if isinstance(item, dict) and item.get("language_code") == "en": + return item.get("text") + + # 3) First available + if json_list and isinstance(json_list[0], dict): + return json_list[0].get("text") + + return None + + +class LocalizedField(serializers.Field): + """ + Extracts the correct language from a JSON list + based on request.LANGUAGE_CODE (LocaleMiddleware), + with sensible fallbacks. + """ + + def to_representation(self, value): + # Expecting value to be a list of {"language_code": "...", "text": "..."} + if not value or not isinstance(value, list): + return None + + # Get language from request, then fall back to global language / 'fa' + request = self.context.get("request") + language_code = getattr(request, "LANGUAGE_CODE", None) if request else None + if not language_code: + language_code = get_language() or "fa" # global active language [web:164][web:172] + + # 1) Exact match with request language + for item in value: + if item.get("language_code") == language_code: + return item.get("text") + + # 2) Fallback to English + for item in value: + if item.get("language_code") == "en": + return item.get("text") + + # 3) Fallback to first item + first = value[0] + return first.get("text") if isinstance(first, dict) else None + + + +class HadisCategorySectListSerializer(serializers.ModelSerializer): + """Serializer for HadisSect list with grouped response""" + source_types = serializers.SerializerMethodField() + title = LocalizedField() + + class Meta: + model = HadisSect + fields = ['id','sect_type', 'title','order', 'description', 'source_types'] + + def get_source_types(self, obj): + """Get unique source types for this sect's categories""" + source_types = HadisCategory.objects.filter( + sect=obj + ).values_list('source_type', flat=True) + # Use set to ensure uniqueness, then convert back to list + return list(set(source_types)) + + + + +class HadisCategoryTreeSerializer(serializers.ModelSerializer): + title = LocalizedField() + + class Meta: + model = HadisCategory + fields = ['id', 'title', 'source_type'] + + +class HadisCategorySelectSerializer(serializers.ModelSerializer): + """Serializer for HadisCategory Selection Flow""" + sect_id = serializers.IntegerField(source='sect.id', read_only=True) + sect_type = serializers.CharField(source='sect.sect_type', read_only=True) + # children = serializers.SerializerMethodField() + children_count = serializers.SerializerMethodField() + has_hadis = serializers.SerializerMethodField() + hadis_count= serializers.SerializerMethodField() + title = LocalizedField() + description =LocalizedField() + + class Meta: + model = HadisCategory + fields = ['id', 'title', 'source_type','slug', 'sect_id', + 'sect_type','description','children_count','has_hadis','hadis_count'] + + def get_has_hadis(self, obj): + """Check if category can have hadis (no active children) and has hadis""" + # Check if category has active children + has_active_children = obj.get_children().filter(sect=obj.sect).exists() + + # If has active children, cannot have hadis + if has_active_children: + return False + + # If no active children, check if has hadis + return Hadis.objects.filter(category=obj, status=True).exists() + + def get_children_count(self, obj): + """Get count of active children categories that have children or hadis""" + children = obj.get_children().filter(sect=obj.sect) + return len(children) + def get_hadis_count(self,obj): + return len(Hadis.objects.filter(category=obj)) + + + + +class HadisCategorySelectSourceSerializer(serializers.ModelSerializer): + """Serializer for HadisCategory Selection Flow""" + sect_id = serializers.IntegerField(source='sect.id', read_only=True) + sect_type = serializers.CharField(source='sect.sect_type', read_only=True) + children_count = serializers.SerializerMethodField() + has_hadis = serializers.SerializerMethodField() + hadis_count = serializers.SerializerMethodField() + title = LocalizedField() + description = LocalizedField() + + + class Meta: + model = HadisCategory + fields = ['id', 'title', 'source_type','slug', 'sect_id', + 'sect_type','description','children_count','has_hadis','hadis_count'] + + def get_has_hadis(self, obj): + """Check if category can have hadis (no active children) and has hadis""" + # Check if category has active children + has_active_children = obj.get_children().filter(sect=obj.sect).exists() + # If has active children, cannot have hadis + if has_active_children: + return False + # If no active children, check if has hadis + return Hadis.objects.filter(category=obj, status=True).exists() + + def get_children_count(self, obj): + """Get count of active children categories that have children or hadis""" + children = obj.get_children().filter(sect=obj.sect , source_type= obj.source_type) + return len(children) + def get_hadis_count(self,obj): + return len(Hadis.objects.filter(category=obj)) + +class CategorySerializer(serializers.ModelSerializer): + sect_id = serializers.IntegerField(source='sect.id', read_only=True) + sect_type = serializers.CharField(source='sect.sect_type', read_only=True) + children_count = serializers.SerializerMethodField() + has_hadis =serializers.SerializerMethodField() + hadis_count=serializers.SerializerMethodField() + title = LocalizedField() + description = LocalizedField() + + + class Meta: + model = HadisCategory + fields = ['id', 'title', 'sect_id', 'sect_type','source_type', + 'description','slug', + 'children_count','has_hadis','hadis_count'] + + def get_children_count(self, obj): + """Get count of active children categories that have children or hadis""" + children = obj.get_children().filter(sect=obj.sect) + return len(children) + def get_has_hadis(self,obj): + return Hadis.objects.filter(category=obj).exists() + def get_hadis_count(self,obj): + return len(Hadis.objects.filter(category=obj)) + + # def get_title(self,obj): + # # ✅ Get language from request + # request = self.context.get('request') + # lang = request.query_params.get('lang', 'ru') if request else 'ru' + + # # ✅ CALL THE MODEL METHOD! + # return obj.get_title(lang) + + diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py new file mode 100644 index 0000000..082da88 --- /dev/null +++ b/apps/hadis/serializers/hadis.py @@ -0,0 +1,661 @@ +from rest_framework import serializers +from django.utils.translation import gettext_lazy as _ +from rest_framework.fields import SerializerMethodField +from urllib3 import request +from .category import LocalizedField +from .category import get_localized_text +from .category import get_localized_text + +from ..models import ( + Hadis, HadisStatus, HadisTag, HadisTransmitter, + HadisReference, ReferenceImage, Transmitters, HadisCollection, + TransmitterOpinion, TransmitterOriginalText, BookReference, BookReferenceImage, BookAuthor, HadisCorrection +) + + + + +class HadisCollectionListSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + title = LocalizedField() + summary = LocalizedField() + + class Meta: + model = HadisCollection + fields = ['id', 'title', 'summary','slug', 'thumbnail'] + + def get_thumbnail(self, obj): + if obj.thumbnail: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.thumbnail.url) + return obj.thumbnail.url + return None + + +class HadisSyncSerializer(serializers.ModelSerializer): + """Serializer for syncing all hadis data (grouped fields)""" + + detail = serializers.SerializerMethodField() + narrators = serializers.SerializerMethodField() + explanations = serializers.SerializerMethodField() + corrections = serializers.SerializerMethodField() + title =LocalizedField() + title_narrator = LocalizedField() + translation = LocalizedField() + + class Meta: + model = Hadis + fields = [ + # header (no-extend) + 'id', 'number', 'slug', 'category_id', 'title', 'title_narrator', 'text', 'translation', + # grouped sections + 'detail', 'narrators', 'explanations', 'corrections', + ] + + def get_detail(self, obj): + request = self.context.get('request') + + # status + status_block = None + if obj.hadis_status: + status_block = { + 'id': obj.hadis_status.id, + 'title': get_localized_text(obj.hadis_status.title, request), + 'color': obj.hadis_status.color, + } + + # tags (already prefetched) + tags_block = [ + {'id': tag.id, 'title': get_localized_text(tag.title, request)} + for tag in obj.tags.all() + ] + + # references and reference images (already prefetched) + references_block = [] + reference_images_block = [] + + for reference in obj.references.all(): + book = reference.book_reference + authors = book.authors.all() if book else [] + references_block.append({ + 'id': reference.id, + 'title': get_localized_text(book.title, request) if book else None, + 'authors': [ + {'id': a.id, 'name': get_localized_text(a.name, request)} + for a in authors + ], + 'description': book.description if book else None, + }) + + for img in reference.images.all(): + thumb_url = None + if img.thumbnail: + thumb_url = request.build_absolute_uri(img.thumbnail.url) if request else img.thumbnail.url + reference_images_block.append({ + 'id': img.id, + 'thumbnail': thumb_url, + 'priority': img.priority, + }) + + return { + 'address': get_localized_text(obj.address, request), + 'hadis_status': status_block, + 'status_text': get_localized_text(obj.hadis_status_text, request), + 'share_link': obj.share_link, + 'links': obj.links, + 'tags': tags_block, + 'references': references_block, + 'reference_images': reference_images_block, + } + + + def get_narrators(self, obj): + request = self.context.get('request') + + transmitters_data = [] + for transmitter_rel in obj.transmitters.all(): + t = transmitter_rel.transmitter + layer = transmitter_rel.narrator_layer + transmitters_data.append({ + 'id': t.id, + 'name': get_localized_text(t.full_name, request), + 'reliability': get_localized_text(t.reliability,request), + 'layer_level': layer.number if layer else None, + 'layer_name': get_localized_text(layer.name, request) if layer else None, + 'is_gap': transmitter_rel.is_gap, + 'birth_year_hijri': t.birth_year_hijri, + 'death_year_hijri': t.death_year_hijri, + 'order': transmitter_rel.order, + }) + + return { + 'description': get_localized_text(obj.description, request), + 'transmitters': transmitters_data, + } + + + def get_explanations(self, obj): + request = self.context.get('request') + return get_localized_text(obj.explanation, request) + + def get_corrections(self, obj): + request = self.context.get('request') + corrections_data = [] + for correction in obj.hadiscorrection_set.all(): + corrections_data.append({ + 'id': correction.id, + 'title': get_localized_text(correction.title, request), + 'description': get_localized_text(correction.description, request), + 'translation': get_localized_text(correction.translation, request), + 'share_link': correction.share_link, + }) + return corrections_data + +class HadisListSerializer(serializers.ModelSerializer): + """Serializer for Hadis list""" + category = serializers.SerializerMethodField() + status = serializers.SerializerMethodField() + translation = LocalizedField() + title = LocalizedField() + title_narrator = LocalizedField() + + class Meta: + model = Hadis + fields = ['id', 'number', 'slug', 'title', 'title_narrator', 'text', + 'translation', 'category', 'status', 'share_link'] + + def get_category(self, obj): + """Get category id and title""" + if not obj.category: + return None + + request = self.context.get('request') + + # Use your helper method + title = get_localized_text(obj.category.title, request) or "" + + # Safe access for sect_type to prevent crashes if sect is missing + # Note: Ensure your view uses .select_related('category__sect') for speed! + sect_type = obj.category.sect.sect_type if obj.category.sect else None + + return { + 'id': obj.category.id, + 'title': title, + 'slug': obj.category.slug, + 'source_type': obj.category.source_type, + 'sect_type': sect_type + } + + def get_status(self, obj): + """Get status id and title""" + if not obj.hadis_status: + return None + + request = self.context.get('request') + + # Use your helper method + title = get_localized_text(obj.hadis_status.title, request) + + return { + 'id': obj.hadis_status.id, + 'title': title, + 'color': obj.hadis_status.color + } + +class HadisStatusSerializer(serializers.ModelSerializer): + """Serializer for HadisStatus""" + title = LocalizedField() + class Meta: + model = HadisStatus + fields = ['id', 'title', 'color'] + + +class HadisTagSerializer(serializers.ModelSerializer): + """Serializer for HadisTag""" + title = LocalizedField() + + class Meta: + model = HadisTag + fields = ['id', 'title'] + + +class TransmitterSerializer(serializers.ModelSerializer): + """Serializer for Transmitters""" + full_name = LocalizedField() + known_as = LocalizedField() + nickname = LocalizedField() + reliability = serializers.SerializerMethodField() + + class Meta: + model = Transmitters + fields = [ + 'id', 'full_name', 'slug','birth_year_hijri', 'death_year_hijri', + "known_as",'nickname','reliability','madhhab','generation' + ] + + def get_reliability(self, obj): + """Serialize the reliability foreign key""" + if obj.reliability: + return { + 'id': obj.reliability.id, + 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'color': obj.reliability.color + } + return None + +class TransmitterShortSerializer(serializers.ModelSerializer): + """Serializer for Transmitters""" + full_name = LocalizedField() + known_as = LocalizedField() + nickname = LocalizedField() + + class Meta: + model = Transmitters + fields = [ + 'id', 'full_name', 'birth_year_hijri', 'death_year_hijri', + "known_as",'nickname','reliability' + ] +class TransmitterOpinionSerializer(serializers.ModelSerializer): + """ Serializer for TransmitterOpinions """ + scholar_name = LocalizedField() + opinion_text = LocalizedField() + status = serializers.SerializerMethodField() + + class Meta: + model = TransmitterOpinion + fields = ['id','transmitter','scholar_name','opinion_text','status'] + + def get_status(self, obj): + """Serialize the opinion status foreign key""" + if obj.status: + request = self.context.get('request') + return { + 'id': obj.status.id, + 'title': get_localized_text(obj.status.title, request), + 'slug': obj.status.slug, + 'color': obj.status.color + } + return None + +class TransmitterOriginalTextSerializer(serializers.ModelSerializer): + """ Serializer for TransmitterOriginalText """ + title = LocalizedField() + text = LocalizedField() + translation =LocalizedField() + + class Meta: + model = TransmitterOriginalText + fields = ['id', 'title', 'text', 'translation', 'share_link'] + + +class TransmitterDetailSerializer(serializers.ModelSerializer): + """ Serializer for Details of Transmitters """ + + full_name = LocalizedField() + known_as = LocalizedField() + nickname = LocalizedField() + kunya = LocalizedField() + origin = LocalizedField() + lived_in = LocalizedField() + died_in = LocalizedField() + description = LocalizedField() + reliability = serializers.SerializerMethodField() + + class Meta: + model = Transmitters + fields = [ + 'id','full_name','kunya','known_as','nickname', + 'origin','lived_in','died_in','birth_year_hijri', + 'death_year_hijri','age_at_death','reliability', + 'madhhab',"in_sahih_muslim","in_sahih_bukhari", + "description",'generation' + ] + + def get_reliability(self, obj): + """Serialize the reliability foreign key""" + if obj.reliability: + return { + 'id': obj.reliability.id, + 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'color': obj.reliability.color + } + return None + +class TransmittersFiltersSerializer(serializers.ModelSerializer): + pass + +class TransmitterSyncSerializer(serializers.ModelSerializer): + """Serializer for syncing all transmitter data for offline mode""" + + biographical = serializers.SerializerMethodField() + scholars_opinions = serializers.SerializerMethodField() + original_texts = serializers.SerializerMethodField() + full_name = LocalizedField() + + class Meta: + model = Transmitters + fields = [ + 'id', 'full_name','slug' ,'biographical', 'scholars_opinions', 'original_texts' + ] + + def get_biographical(self, obj): + """Get biographical information (flattened)""" + request = self.context.get('request') # ← FIX: Define request + if obj.reliability: + r= { + 'id': obj.reliability.id, + 'title': get_localized_text(obj.reliability.title, self.context.get('request')), + 'color': obj.reliability.color + } + else : + r= None + + return { + 'full_name': get_localized_text(obj.full_name, request), + 'kunya': get_localized_text(obj.kunya, request), + 'known_as': get_localized_text(obj.known_as, request), + 'nickname': get_localized_text(obj.nickname, request), + 'origin': get_localized_text(obj.origin, request), + 'lived_in': get_localized_text(obj.lived_in, request), + 'died_in': get_localized_text(obj.died_in, request), + 'birth_year_hijri': obj.birth_year_hijri, + 'death_year_hijri': obj.death_year_hijri, + 'age_at_death': obj.age_at_death, + 'generation': obj.generation, + 'reliability': r, + 'madhhab': obj.madhhab, + 'in_sahih_muslim': obj.in_sahih_muslim, + 'in_sahih_bukhari': obj.in_sahih_bukhari, + 'description': get_localized_text(obj.description, request), + 'thumbnail': obj.thumbnail.url if obj.thumbnail else None, + } + + def get_scholars_opinions(self, obj): + """Get all scholarly opinions about this transmitter""" + request = self.context.get('request') # ← FIX: Define request + + return [ + { + 'id': opinion.id, + 'scholar_name': get_localized_text(opinion.scholar_name, request), + 'opinion_text': get_localized_text(opinion.opinion_text, request), + 'status': { + 'id': opinion.status.id, + 'title': get_localized_text(opinion.status.title, request), + 'slug': opinion.status.slug, + 'color': opinion.status.color + } if opinion.status else None, + 'created_at': opinion.created_at.isoformat() if opinion.created_at else None, + 'updated_at': opinion.updated_at.isoformat() if opinion.updated_at else None, + } + for opinion in obj.opinions.all() # Already prefetched + ] + + def get_original_texts(self, obj): + """Get original texts of the transmitter""" + request = self.context.get('request') # ← FIX: Define request + + return [ + { + 'id': t.id, + 'title': get_localized_text(t.title, request), + 'text': get_localized_text(t.text, request), + 'translation': get_localized_text(t.translation, request), + 'share_link': t.share_link, + } + for t in obj.originaltexts.all() # Already prefetched + ] + + + +class HadisTransmitterSerializer(serializers.ModelSerializer): + + """Serializer for HadisTransmitter with transmitter details""" + transmitter = TransmitterShortSerializer(read_only=True) + narrator_layer_description = serializers.SerializerMethodField() + layer = serializers.SerializerMethodField() + status = serializers.SerializerMethodField() + class Meta: + model = HadisTransmitter + fields = [ + 'id', 'order', 'is_gap','narrator_layer_description','layer', 'transmitter', 'status' + ] + + def get_narrator_layer_description(self, obj): + """Get narrator layer description""" + # ✅ Get language from request + request = self.context.get('request') + return get_localized_text(obj.narrator_layer.description,request) + + + def get_layer(self, obj): + """Get narrator layer slug""" + return obj.narrator_layer.slug if obj.narrator_layer else None + + def get_status(self, obj): + """Serialize the status foreign key""" + if obj.status: + request = self.context.get('request') + return { + 'id': obj.status.id, + 'title': get_localized_text(obj.status.title, request), + 'slug': obj.status.slug, + 'color': obj.status.color + } + return None + +# serializers.py + +class HadisTransmitterListSerializer(serializers.ModelSerializer): + """ + The 'Parent' Serializer. + It takes a HADIS object and returns the count + the list of transmitters. + """ + layer_count = serializers.SerializerMethodField() + layer_names = serializers.SerializerMethodField() + results = HadisTransmitterSerializer( + source='transmitters', # Access the 'transmitters' reverse relation + many=True, + read_only=True + ) + + class Meta: + model = Hadis + fields = ['id', 'layer_count','layer_names', 'results'] + + def get_layer_count(self, obj): + # Calculate distinct layers efficiently + return obj.transmitters.values('narrator_layer').distinct().count() + def get_layer_names(self, obj): + """Get list of localized narrator layer names""" + request = self.context.get('request') + + # Get distinct narrator layer objects (not just IDs) + layers = obj.transmitters.values_list( + 'narrator_layer', flat=True + ).distinct() + + # Import here to get actual objects + from apps.hadis.models import NarratorLayer + + layer_objects = NarratorLayer.objects.filter(id__in=layers) + + # Extract localized names + layer_names = [ + get_localized_text(layer.name, request=request) + for layer in layer_objects + ] + + return layer_names + +class ReferenceImageSerializer(serializers.ModelSerializer): + """Serializer for ReferenceImage""" + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = ReferenceImage + fields = ['id', 'thumbnail', 'priority'] + + def get_thumbnail(self, obj): + """Get thumbnail URL""" + if obj.thumbnail: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.thumbnail.url) + return obj.thumbnail.url + return None + + +class HadisReferenceSerializer(serializers.ModelSerializer): + """Serializer for HadisReference with book and images""" + book_title = serializers.SerializerMethodField() + book_authors = serializers.SerializerMethodField() + book_description = serializers.SerializerMethodField() + class Meta: + model = HadisReference + fields = [ + 'id', 'book_title','book_authors', 'book_description' + ] + + def get_book_title(self, obj): + """Get book title""" + # ✅ Get language from request + request = self.context.get('request') + lang = request.query_params.get('lang', 'ru') if request else 'ru' + # ✅ CALL THE MODEL METHOD! + try : + title = obj.book_reference.title.get(lang) + return title + except: + return None + + + # def get_book_images(self, obj): + # """Get book images""" + # try : + # images = obj.book_reference.images.all() + # return images + # except: + # return None + + def get_book_authors(self, obj): + """Get book authors""" + try : + authors = obj.book_reference.authors.all() + return authors + except: + return None + + + def get_book_description(self, obj): + """Get book description""" + # ✅ Get language from request + request = self.context.get('request') + lang = request.query_params.get('lang', 'ru') if request else 'ru' + try : + description = obj.book_reference.description.get(lang) + return description + except: + return None + +class HadisCorrectionSerializer(serializers.ModelSerializer): + """Serializer for HadisCorrection""" + title = LocalizedField() + description = LocalizedField() + translation = LocalizedField() + class Meta: + model = HadisCorrection + fields = ['id', 'title','slug', 'description', 'translation','share_link'] + + +class HadisBasicSerializer(serializers.ModelSerializer): + """Basic serializer for Hadis with minimal information""" + translation = LocalizedField() + category = serializers.SerializerMethodField() + + title = LocalizedField() + title_narrator = LocalizedField() + explanation = LocalizedField() + + class Meta: + model = Hadis + fields = [ + 'id', 'slug', 'title', 'title_narrator', 'text', + 'translation', 'share_link','explanation','category' + ] + + + def get_category(self, obj): + """Get category id and title""" + if obj.category: + request = self.context.get('request') + + return { + 'id': obj.category.id, + 'title': get_localized_text(obj.title,request), + 'slug':obj.category.slug, + 'source_type':obj.category.source_type, + 'sect_type':obj.category.sect.sect_type + } + return None + +class HadisShortSerializer(serializers.ModelSerializer): + """Basic serializer for Hadis with minimal information""" + translation = LocalizedField() + + title = LocalizedField() + title_narrator = LocalizedField() + + class Meta: + model = Hadis + fields = [ + 'id', 'slug', 'title', 'title_narrator', 'text', + 'translation', 'share_link'] + + + +class HadisDetailSerializer(serializers.ModelSerializer): + """Detailed serializer for Hadis with core information (transmitters and corrections moved to separate endpoints)""" + hadis_status = HadisStatusSerializer(read_only=True) + tags = HadisTagSerializer(many=True, read_only=True) + references = HadisReferenceSerializer( + many=True, + read_only=True + ) + reference_images = SerializerMethodField() + hadis_status_text = LocalizedField() + address = LocalizedField() + + class Meta: + model = Hadis + fields = [ + 'id', 'number', 'slug', + 'hadis_status_text','hadis_status', 'links','share_link', + 'tags', 'references','reference_images','address' + ] + + def get_reference_images(self, obj): + """Get all reference images from all references""" + all_images = [] + for reference in obj.references.all(): + images = reference.images.all().order_by('priority') + serializer = ReferenceImageSerializer(images, many=True, context=self.context) + all_images.extend(serializer.data) + return all_images + + # def get_category(self, obj): + # """Get category details""" + # if obj.category: + # return { + # 'id': obj.category.id, + # 'title': obj.category.title, + # 'category_type': obj.category.content_type + # } + # return None + + def get_translation(self, obj): + """Get translation based on request language""" + request = self.context.get('request') + language_code = getattr(request, 'LANGUAGE_CODE', 'en') + return obj.translation.get(language_code) diff --git a/apps/hadis/serializers/reference.py b/apps/hadis/serializers/reference.py new file mode 100644 index 0000000..75e12b1 --- /dev/null +++ b/apps/hadis/serializers/reference.py @@ -0,0 +1,169 @@ +from rest_framework import serializers +from django.utils.translation import get_language +from .category import get_localized_text + +from ..serializers import HadisBasicSerializer,LocalizedField, HadisShortSerializer +from ..models import BookReference , BookAuthor , BookReferenceImage , BookAttribute + + +class BookAuthorSerializer(serializers.ModelSerializer): + name = LocalizedField() + class Meta: + model = BookAuthor + fields = ['id','name'] + +class BookReferenceImageSerializer(serializers.ModelSerializer): + description = LocalizedField() + class Meta: + model = BookReferenceImage + fields = ['id','image','description','order'] + +class BookReferenceSerializer(serializers.ModelSerializer): + image = BookReferenceImageSerializer( + many= True , + read_only = True , + source = 'bookreference_set' + ) + title = LocalizedField() + description = LocalizedField() + author = serializers.SerializerMethodField() + class Meta: + model = BookReference + fields = ['id','title','slug','rate','author','description','image','volume'] + def get_author(self, obj): + request = self.context.get("request") + # Prefer request.LANGUAGE_CODE if you use LocaleMiddleware + language_code = getattr(request, "LANGUAGE_CODE", None) if request else None + print(language_code,1) + if not language_code: + language_code =get_language() or "en" # fallback [web:164][web:165] + + print(language_code,2) + + authors = obj.authors.all() + result = [] + + for author in authors: + # author.name is your list of dicts + name_items = author.name or [] + # find item with matching language_code + text = None + for item in name_items: + if item.get("language_code") == language_code: + print(f'we got language-code{language_code}') + text = item.get("text") + break + + # fallback: if not found, use first item or None + print(text,'this is text') + if text is None and name_items: + print('no we didnt') + + text = name_items[0].get("text") + + result.append( + { + "id": author.id, + "name": text, + } + ) + + return result + +class BookAttributeSerializer(serializers.ModelSerializer): + title = LocalizedField() + value = LocalizedField() + class Meta: + model = BookAttribute + fields = ['id', 'title', 'value','book_reference'] + +class BookDetailSerializer(serializers.ModelSerializer): + + attribute = BookAttributeSerializer( + many=True, + read_only = True, + source = 'attributes' + ) + author= BookAuthorSerializer( + many=True, + read_only=True, + source='authors' + ) + image = BookReferenceImageSerializer( + many=True, + read_only=True, + source='images' + ) + + # hadis = HadisListSerializer( + # many=True, + # read_only=True, + # source='hadis_references__hadis' + # ) + + hadis = serializers.SerializerMethodField() + title = LocalizedField() + language = LocalizedField() + publisher = LocalizedField() + description = LocalizedField() + + class Meta: + model = BookReference + fields = ['id','title','rate','isbn','language','number_page','publisher','description','volume','slug','attribute','author','image','hadis'] + + def get_hadis(self,obj): + references = obj.hadis_references.all() + hadis_list = [ref.hadis for ref in references if ref.hadis] + return HadisBasicSerializer(hadis_list,many=True).data + + + +class BookReferenceSyncSerializer(serializers.ModelSerializer): + """Serializer for syncing all book reference data for offline mode""" + + attribute = BookAttributeSerializer( + many=True, + read_only=True, + source='attributes' + ) + author = BookAuthorSerializer( + many=True, + read_only=True, + source='authors' + ) + image = BookReferenceImageSerializer( + many=True, + read_only=True, + source='images' + ) + + # Basic information + detail = serializers.SerializerMethodField() + hadises = serializers.SerializerMethodField() + title = LocalizedField() + + class Meta: + model = BookReference + fields = [ + 'id', 'title', 'rate', 'author', 'detail', 'image', 'attribute', 'hadises' + ] + + def get_detail(self, obj): + """Get basic book information""" + request = self.context.get('request') + + return { + 'description': get_localized_text(obj.description, request), + 'volume': obj.volume, + 'language': get_localized_text(obj.language, request), + 'isbn': obj.isbn, + 'number_page': obj.number_page, + 'year_of_publication': obj.year_of_publication, + 'volume_info': obj.volume, + 'rating': obj.rate + } + + def get_hadises(self,obj): + references = obj.hadis_references.all() + hadis_list = [ref.hadis for ref in references if ref.hadis] + return HadisShortSerializer(hadis_list,many=True).data \ No newline at end of file diff --git a/apps/hadis/serializers/version.py b/apps/hadis/serializers/version.py new file mode 100644 index 0000000..4eb6036 --- /dev/null +++ b/apps/hadis/serializers/version.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from ..models import ContentRelease + + +class ContentReleaseSyncSerializer(serializers.ModelSerializer): + """Serializer for syncing content release data for offline mode""" + + class Meta: + model = ContentRelease + fields = [ + 'id', + 'version_name', + 'published_at', + 'description', + 'is_active' + ] diff --git a/apps/hadis/signals.py b/apps/hadis/signals.py new file mode 100644 index 0000000..dd61a7f --- /dev/null +++ b/apps/hadis/signals.py @@ -0,0 +1,33 @@ +# hadith_app/signals.py + +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from django.core.cache import cache +from .models import * + +# 1. Define all models that affect the list +# If a Category title changes, the list must update. +# If a Status changes, the list must update. +TARGET_MODELS = [Hadis , HadisCategory , HadisCollection , HadisCorrection , HadisInCollection , HadisReference , + HadisSect , HadisStatus , HadisTag , HadisTransmitter , Transmitters ,TransmitterOpinion , TransmitterReliability, + TransmitterOriginalText,ReferenceImage , BookReference , BookAttribute, BookAuthor ,BookReferenceImage, + NarratorLayer , OpinionStatus ] + +@receiver(post_save) +@receiver(post_delete) +def clear_hadis_cache(sender, instance, **kwargs): + """ + Clears the API cache whenever a Hadith or related model is saved/deleted. + """ + if sender in TARGET_MODELS: + # This is the magic command from django-redis + # It finds ALL keys starting with the prefix and deletes them + # *:1: is the default django version prefix + try: + # Delete any key that contains our prefix "hadis_api" + # The pattern "*hadis_api*" ensures we catch all variations (headers, pages, etc) + cache.delete_pattern("*hadis_api*") + print(f"Cache cleared for {sender.__name__} update!") + except Exception as e: + # Fail silently or log error, don't crash the save transaction + print(f"Cache clear failed: {e}") \ No newline at end of file diff --git a/apps/hadis/tests.py b/apps/hadis/tests.py index 7ce503c..2cfff0c 100644 --- a/apps/hadis/tests.py +++ b/apps/hadis/tests.py @@ -1,3 +1,335 @@ -from django.test import TestCase +from django.test import TestCase, override_settings +from django.urls import reverse, resolve +from rest_framework.test import APITestCase +from rest_framework import status +from django.test.utils import override_settings -# Create your tests here. + +class HadisAPIConnectivityTests(APITestCase): + """ + Test suite for hadis app API endpoints connectivity. + Tests that all endpoints return proper HTTP responses. + """ + + # Comment out fixtures for now to avoid migration issues + # fixtures = [ + # 'backend/apps/hadis/fixtures/new_categories.json', + # 'backend/apps/hadis/fixtures/hadises1_reformatted.json', + # 'backend/apps/hadis/fixtures/transmitters_reformatted.json', + # ] + + def test_collections_endpoint(self): + """Test collections endpoint returns 200""" + url = reverse('hadis-collection-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_sync_sects_endpoint(self): + """Test sync sects endpoint returns 200""" + url = reverse('hadis-sect-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_sync_categories_tree_endpoint(self): + """Test sync categories tree endpoint returns 200""" + url = reverse('hadis-category-tree') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_sync_hadis_endpoint(self): + """Test sync hadis endpoint returns 200""" + url = reverse('hadis-sync') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_sync_narrators_endpoint(self): + """Test sync narrators endpoint returns 200""" + url = reverse('transmitter-sync') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_sync_references_endpoint(self): + """Test sync references endpoint returns 200""" + url = reverse('reference-sync') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_info_endpoint(self): + """Test info endpoint returns 200""" + url = reverse('hadis-info') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_categories_tree_normal_endpoint(self): + """Test categories tree normal endpoint returns 200""" + url = reverse('hadis-category-tree-normal') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_categories_endpoint(self): + """Test categories endpoint returns 200""" + url = reverse('categories') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_categories_by_sect_endpoint(self): + """Test categories by sect endpoint is accessible""" + # Using a common sect type + url = reverse('categories-by-sect', kwargs={'sect_type': '1'}) + response = self.client.get(url) + # May return 200 if data exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_categories_by_sect_with_slug_endpoint(self): + """Test categories by sect with slug endpoint is accessible""" + # Using common parameters + url = reverse('categories-tree-by-sect', kwargs={ + 'sect_type': '1', + 'slug': 'test-category' + }) + response = self.client.get(url) + # May return 200 if data exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_categories_by_sect_source_endpoint(self): + """Test categories by sect source endpoint is accessible""" + # Using common parameters + url = reverse('categories-tree-by-sect-source', kwargs={ + 'sect_type': '1', + 'slug': 'test-category', + 'source_type': 'quran' + }) + response = self.client.get(url) + # May return 200 if data exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_hadis_main_list_endpoint(self): + """Test hadis main list (arguments) endpoint returns 200""" + url = reverse('hadis-main-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_hadis_filters_endpoint(self): + """Test hadis filters endpoint returns 200""" + url = reverse('hadis-filters') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_narrators_endpoint(self): + """Test narrators endpoint returns 200""" + url = reverse('narrators') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_narrator_filters_endpoint(self): + """Test narrator filters endpoint returns 200""" + url = reverse('narrator-filters') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_narrator_detail_endpoint(self): + """Test narrator detail endpoint is accessible""" + # Using a test narrator slug + url = reverse('narrator-detail', kwargs={'narrator_slug': 'test-narrator'}) + response = self.client.get(url) + # May return 200 if narrator exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_narrator_opinions_endpoint(self): + """Test narrator opinions endpoint is accessible""" + # Using a test narrator slug + url = reverse('narrator-opinions', kwargs={'narrator_slug': 'test-narrator'}) + response = self.client.get(url) + # May return 200 if narrator exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_narrator_original_texts_endpoint(self): + """Test narrator original texts endpoint is accessible""" + # Using a test narrator slug + url = reverse('narrator-original-texts', kwargs={'narrator_slug': 'test-narrator'}) + response = self.client.get(url) + # May return 200 if narrator exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_references_endpoint(self): + """Test references endpoint returns 200""" + url = reverse('references') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_category_hadis_list_endpoint(self): + """Test category hadis list endpoint is accessible""" + # Using a test category slug + url = reverse('hadis-list', kwargs={'category_slug': 'test-category'}) + response = self.client.get(url) + # May return 200 if category exists, or 404 if not - but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + # Parameterized endpoints that may not have data - these might return 404 but should still be accessible + def test_reference_detail_endpoint(self): + """Test reference detail endpoint is accessible (may return 404 if no data)""" + url = reverse('reference-detail', kwargs={'reference_slug': 'test-reference'}) + response = self.client.get(url) + # This might return 404 if reference doesn't exist, but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_hadis_basic_endpoint(self): + """Test hadis basic endpoint is accessible (may return 404 if no data)""" + url = reverse('hadis-basic', kwargs={'hadis_slug': 'test-hadis'}) + response = self.client.get(url) + # This might return 404 if hadis doesn't exist, but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_hadis_detail_endpoint(self): + """Test hadis detail endpoint is accessible (may return 404 if no data)""" + url = reverse('hadis-detail', kwargs={'hadis_slug': 'test-hadis'}) + response = self.client.get(url) + # This might return 404 if hadis doesn't exist, but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_hadis_transmitters_endpoint(self): + """Test hadis transmitters endpoint is accessible (may return 404 if no data)""" + url = reverse('hadis-transmitters', kwargs={'hadis_slug': 'test-hadis'}) + response = self.client.get(url) + # This might return 404 if hadis doesn't exist, but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + def test_hadis_corrections_endpoint(self): + """Test hadis corrections endpoint is accessible (may return 404 if no data)""" + url = reverse('hadis-corrections', kwargs={'hadis_slug': 'test-hadis'}) + response = self.client.get(url) + # This might return 404 if hadis doesn't exist, but endpoint should be accessible + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]) + + +class HadisAPIResponseStructureTests(APITestCase): + """ + Additional tests to ensure API responses have proper structure + """ + + # Comment out fixtures for now to avoid migration issues + # fixtures = [ + # 'backend/apps/hadis/fixtures/new_categories.json', + # 'backend/apps/hadis/fixtures/hadises1_reformatted.json', + # 'backend/apps/hadis/fixtures/transmitters_reformatted.json', + # ] + + def test_api_returns_json_content_type(self): + """Test that API endpoints return JSON content type""" + endpoints = [ + reverse('hadis-collection-list'), + reverse('hadis-info'), + reverse('categories'), + reverse('hadis-main-list'), + reverse('narrators'), + ] + + for url in endpoints: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response['Content-Type'], 'application/json') + + def test_api_endpoints_are_cached(self): + """Test that API endpoints have cache headers (from cached_view decorator)""" + url = reverse('hadis-info') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Check if cache headers are present (this indicates caching is working) + cache_headers = ['Cache-Control', 'Expires', 'ETag'] + has_cache_header = any(header in response for header in cache_headers) + self.assertTrue(has_cache_header, "API endpoint should have cache headers") + + +class HadisURLResolutionTests(TestCase): + """ + Test that all hadis URLs are properly configured and resolvable. + These tests don't require database access. + """ + + def test_all_url_names_are_resolvable(self): + """Test that all URL names can be reversed without parameters""" + url_names = [ + 'hadis-collection-list', + 'hadis-sect-list', + 'hadis-category-tree', + 'hadis-sync', + 'transmitter-sync', + 'reference-sync', + 'hadis-info', + 'hadis-category-tree-normal', + 'categories', + 'hadis-main-list', + 'hadis-filters', + 'narrator-filters', + 'narrators', + 'references', + ] + + for url_name in url_names: + with self.subTest(url_name=url_name): + try: + url = reverse(url_name) + # If we get here, the URL is resolvable + self.assertIsInstance(url, str) + self.assertTrue(url.startswith('/')) + except Exception as e: + self.fail(f"URL name '{url_name}' could not be reversed: {e}") + + def test_parameterized_url_names_are_resolvable(self): + """Test that parameterized URL names can be reversed with test parameters""" + parameterized_urls = [ + ('categories-by-sect', {'sect_type': '1'}), + ('categories-tree-by-sect', {'sect_type': '1', 'slug': 'test'}), + ('categories-tree-by-sect-source', {'sect_type': '1', 'slug': 'test', 'source_type': 'quran'}), + ('hadis-list', {'category_slug': 'test'}), + ('narrator-detail', {'narrator_slug': 'test'}), + ('narrator-opinions', {'narrator_slug': 'test'}), + ('narrator-original-texts', {'narrator_slug': 'test'}), + ('reference-detail', {'reference_slug': 'test'}), + ('hadis-basic', {'hadis_slug': 'test'}), + ('hadis-detail', {'hadis_slug': 'test'}), + ('hadis-transmitters', {'hadis_slug': 'test'}), + ('hadis-corrections', {'hadis_slug': 'test'}), + ] + + for url_name, kwargs in parameterized_urls: + with self.subTest(url_name=url_name): + try: + url = reverse(url_name, kwargs=kwargs) + # If we get here, the URL is resolvable + self.assertIsInstance(url, str) + self.assertTrue(url.startswith('/')) + except Exception as e: + self.fail(f"URL name '{url_name}' could not be reversed with kwargs {kwargs}: {e}") + + def test_urls_resolve_to_correct_views(self): + """Test that URLs resolve to the correct view functions/classes""" + test_urls = [ + ('/hadis/collections/', 'hadis-collection-list'), + ('/hadis/sync/sects/', 'hadis-sect-list'), + ('/hadis/sync/categories/tree/', 'hadis-category-tree'), + ('/hadis/sync/hadis/', 'hadis-sync'), + ('/hadis/sync/narrators/', 'transmitter-sync'), + ('/hadis/sync/references/', 'reference-sync'), + ('/hadis/info/', 'hadis-info'), + ('/hadis/categories/tree/', 'hadis-category-tree-normal'), + ('/hadis/categories/', 'categories'), + ('/hadis/arguments/', 'hadis-main-list'), + ('/hadis/arguments/filters/', 'hadis-filters'), + ('/hadis/narrators/filters/', 'narrator-filters'), + ('/hadis/narrators/', 'narrators'), + ('/hadis/references/', 'references'), + ] + + for url_path, expected_url_name in test_urls: + with self.subTest(url_path=url_path): + try: + resolved = resolve(url_path) + # Check that the URL resolves + self.assertIsNotNone(resolved) + # Check that we can reverse back to the same URL + reversed_url = reverse(expected_url_name) + self.assertEqual(url_path, reversed_url) + except Exception as e: + self.fail(f"URL '{url_path}' could not be resolved: {e}") diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index bf75802..111ccdd 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -1,12 +1,56 @@ -from django.urls import path, include -from . import views +from django.urls import path +from .views.category import HadisCategorySectListView, HadisCategoryTreeView, CategoriesView, CategoriesBySectView, HadisCategorySelectBySectView, HadisCategorySelectBySectSourceView , HadisCategoryTreeNormalView +from .views.hadis import HadisCollectionListView, HadisListView, HadisBasicView, HadisDetailView, HadisSyncView, HadisTransmittersView, HadisCorrectionsView,HadisMainListView, HadisFiltersView +from .views.transmitter import TransmitterView ,TransmitterDetailView, TransmitterSyncView,TransmitterOpinionView, TransmitterOriginalTextView, TransmitterFiltersView +from .views.reference import BookDetailView, BookReferencesView, BookReferenceSyncView, BookAttributeView +from .views.version import ContentReleaseSyncView +from .views.info import HadisInfoView +from django.views.decorators.cache import cache_page +from django.views.decorators.vary import vary_on_headers + +# Helper function to avoid ugly nesting +def cached_view(view_func): + return cache_page(60*60*2,key_prefix='hadis_api')(vary_on_headers('Accept-Language')(view_func)) urlpatterns = [ - path('categories/', views.CategoryListView.as_view(), name='category-list'), + # Most specific first (with parameters) + path('collections/', cached_view(HadisCollectionListView.as_view()), name='hadis-collection-list'), + path('sync/sects/', cached_view(HadisCategorySectListView.as_view()), name='hadis-sect-list'), + path('sync/categories/tree/', cached_view(HadisCategoryTreeView.as_view()), name='hadis-category-tree'), + path('sync/hadis/', cached_view(HadisSyncView.as_view()), name='hadis-sync'), + path('sync/narrators/', cached_view(TransmitterSyncView.as_view()), name='transmitter-sync'), + path('sync/references/', cached_view(BookReferenceSyncView.as_view()), name='reference-sync'), + path('sync/version/', ContentReleaseSyncView.as_view(), name='content-release-sync'), + path('info/', cached_view(HadisInfoView.as_view()), name='hadis-info'), + + # Category paths (more specific first) + path('categories/tree/', cached_view(HadisCategoryTreeNormalView.as_view()), name='hadis-category-tree-normal'), + path('categories////', cached_view(HadisCategorySelectBySectSourceView.as_view()), name='categories-tree-by-sect-source'), + path('categories///', cached_view(HadisCategorySelectBySectView.as_view()), name='categories-tree-by-sect'), + path('categories//', cached_view(CategoriesBySectView.as_view()), name='categories-by-sect'), + path('categories/', cached_view(CategoriesView.as_view()), name='categories'), # ← Least specific LAST + + # Hadis paths + path('category//', cached_view(HadisListView.as_view()), name='hadis-list'), + path('arguments/', cached_view(HadisMainListView.as_view()), name='hadis-main-list'), + path('arguments/filters/', cached_view(HadisFiltersView.as_view()), name='hadis-filters'), + + # Narrator paths + path('narrators//opinions', cached_view(TransmitterOpinionView.as_view()), name='narrator-opinions'), + path('narrators//original_texts', cached_view(TransmitterOriginalTextView.as_view()), name='narrator-original-texts'), + path('narrators/', cached_view(TransmitterDetailView.as_view()), name='narrator-detail'), + path('narrators/filters/', cached_view(TransmitterFiltersView.as_view()), name='narrator-filters'), + path('narrators/', cached_view(TransmitterView.as_view()), name='narrators'), + + # Reference paths + path('references/', cached_view(BookDetailView.as_view()), name='reference-detail'), + path('references/', cached_view(BookReferencesView.as_view()), name='references'), - path('categories//hadis/', views.CategoryHadisListView.as_view(), name='category-hadis-list'), - path('/', views.HadisDetailView.as_view(), name='hadis-detail'), + # Hadis detail paths (with slug, more specific) + path('/detail/', cached_view(HadisDetailView.as_view()), name='hadis-detail'), + path('/transmitters/', cached_view(HadisTransmittersView.as_view()), name='hadis-transmitters'), + path('/corrections/', cached_view(HadisCorrectionsView.as_view()), name='hadis-corrections'), + path('/', cached_view(HadisBasicView.as_view()), name='hadis-basic'), # ← Least specific LAST - -] \ No newline at end of file +] diff --git a/apps/hadis/views/__init__.py b/apps/hadis/views/__init__.py index b239bfe..4fe1bee 100644 --- a/apps/hadis/views/__init__.py +++ b/apps/hadis/views/__init__.py @@ -1,3 +1,7 @@ from .category import * from .hadis import * -# from .transmitter import * \ No newline at end of file +<<<<<<< HEAD +# from .transmitter import * +======= +from .info import * +>>>>>>> develop diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index df35e82..6176c96 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -1,301 +1,300 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch, Case, When, Value, IntegerField -from rest_framework.pagination import PageNumberPagination from rest_framework.generics import ListAPIView -from django.core.cache import cache -from django.conf import settings -import hashlib -import json - - -from apps.hadis.models import * -from apps.hadis.serializers import * -from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger - +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from utils.pagination import NoPagination +from django.db.models import Q + +from ..models import HadisSect, HadisCategory,Hadis +from ..serializers import ( + HadisCategorySectListSerializer, + HadisCategoryTreeSerializer, + CategorySerializer , + HadisCategorySelectSerializer , + HadisCategorySelectSourceSerializer, + get_localized_text + ) +from ..docs import ( + hadis_sect_list_swagger, + hadis_category_tree_swagger, + categories_list_swagger, + categories_by_sect_swagger, + categories_tree_by_sect_swagger, + categories_tree_by_sect_source_swagger +) + + +class HadisCategorySectListView(ListAPIView): + """ + API view to list all HadisSects grouped by sect_type (shia/sunni) + """ + queryset = HadisSect.objects.filter(is_active=True).order_by('order') + serializer_class = HadisCategorySectListSerializer + pagination_class = NoPagination + + @hadis_sect_list_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) -class CategoryPagination(PageNumberPagination): - page_size = 10 - page_size_query_param = 'page_size' - max_page_size = 100 +class HadisCategoryTreeView(ListAPIView): + """ + API view to get all HadisCategory tree structure grouped by sect + """ + serializer_class = HadisCategoryTreeSerializer + pagination_class = NoPagination -class CategoryListView(ListAPIView): - serializer_class = HadisCategorySerializer - permission_classes = (IsAuthenticated,) - pagination_class = CategoryPagination - # Cache timeout in seconds (1 hour) - CACHE_TIMEOUT = 60 * 60 + @hadis_category_tree_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) - def get_cache_key(self, source_type=None): + def get_queryset(self): """ - Generate a unique cache key based on the view name and filter parameters. - - Args: - source_type: Optional source_type filter parameter - - Returns: - A unique cache key string + Prefetch ALL data at once to avoid N+1 """ - # Base key with the view name - key_parts = ['category_tree'] - - # Add filter parameters to make the key specific - if source_type: - key_parts.append(f'source_type:{source_type}') + from django.db.models import Prefetch, Count, Q + + # Create a queryset for children that also has annotations + children_queryset = ( + HadisCategory.objects + .select_related('sect') + .prefetch_related('children') + .annotate( + hadis_count=Count('hadis', filter=Q(hadis__status=True)), + children_count=Count('children', distinct=True) + ) + .order_by('order') + ) + + return ( + HadisCategory.objects + .filter(parent__isnull=True, sect__is_active=True) + .select_related('sect') + .prefetch_related( + Prefetch( + 'children', + queryset=children_queryset + ), + ) + .annotate( + hadis_count=Count('hadis', filter=Q(hadis__status=True)), + children_count=Count('children', distinct=True) + ) + .order_by('sect__order', 'order') + ) - # Join all parts with a separator - key = ':'.join(key_parts) - return key + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + grouped_data = {} + + # Single pass through prefetched data (NO QUERIES) + for category in queryset: + sect_type = category.sect.sect_type + + if sect_type not in grouped_data: + grouped_data[sect_type] = { + 'sects': {}, + 'categories': [] + } + + # Add sect info + sect_id = str(category.sect.id) + if sect_id not in grouped_data[sect_type]['sects']: + source_types = HadisCategory.objects.filter( + sect=category.sect + ).values_list('source_type', flat=True) + grouped_data[sect_type]['sects'][sect_id] = { + 'id': category.sect.id, + 'sect_type': category.sect.sect_type, + 'title': get_localized_text(category.sect.title, request), + 'description': get_localized_text(category.sect.description,request), + 'order': category.sect.order, + 'source_types':list(set(source_types)) + } + + # Build tree using prefetched data + category_data = self._build_tree(category, request) + grouped_data[sect_type]['categories'].append(category_data) + + # Count total categories (simple loop, no queries) + total_count = self._count_tree_items(grouped_data) + + response_data = { + 'count': total_count, + 'results': grouped_data + } - @classmethod - def invalidate_cache(cls, source_type=None): - """ - Invalidate the category tree cache. + return Response(response_data) - Args: - source_type: Optional source_type to invalidate specific cache. - If None, invalidates all category tree caches. + def _build_tree(self, category, request): """ - if source_type: - # Invalidate specific tree cache - tree_cache_key = cls().get_cache_key(source_type) - cache.delete(tree_cache_key) - - # Invalidate all paginated caches for this source_type - paginated_pattern = f'category_tree_paginated:source_type:{source_type}*' - paginated_keys = cache.keys(paginated_pattern) - if paginated_keys: - cache.delete_many(paginated_keys) - else: - # Invalidate all category tree caches (both full trees and paginated results) - # This uses cache key pattern matching if supported by the cache backend - # For Redis, we can use wildcards - all_cache_keys = cache.keys('category_tree*') - if all_cache_keys: - cache.delete_many(all_cache_keys) - else: - # Fallback: delete specific known keys - for st in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]: - # Delete tree cache - tree_cache_key = cls().get_cache_key(st) - cache.delete(tree_cache_key) - - # Try to delete paginated caches - try: - paginated_pattern = f'category_tree_paginated:source_type:{st}*' - paginated_keys = cache.keys(paginated_pattern) - if paginated_keys: - cache.delete_many(paginated_keys) - except: - pass - - # Also delete the default keys (no source_type) - cache.delete(cls().get_cache_key()) - try: - default_paginated_keys = cache.keys('category_tree_paginated:page:*') - if default_paginated_keys: - cache.delete_many(default_paginated_keys) - except: - pass - - def get_children(self, obj): - return [self.to_dict(cat) for cat in obj.get_children()] - - def to_dict(self, c): - """ - Convert a category to a dictionary with proper tree structure based on level. - - Args: - c: The HadisCategory instance - - Returns: - Dictionary representation of the category with proper tree structure + Build tree from ALREADY PREFETCHED data + No database queries here! """ - # Get the level of this category - level = c.level_p - - # Determine source_type and category_type based on level - source_type = None - category_type = None - - if level == 1: - # Level 1 (Root) - Has its own source_type - source_type = c.source_type - category_type = None - elif level == 2: - # Level 2 (Child) - Inherits source_type from parent, has own category_type - if c.parent: - source_type = c.parent.source_type - else: - source_type = c.source_type - category_type = c.category_type - elif level == 3: - # Level 3 (Grandchild) - Inherits source_type from grandparent, category_type from parent - if c.parent and c.parent.parent: - source_type = c.parent.parent.source_type - category_type = c.parent.category_type - else: - source_type = c.source_type - category_type = c.category_type - - # Get direct children - use getattr to handle both model instances and cached trees - if hasattr(c, 'get_children'): - # For model instances - children = c.get_children() - else: - # For cached trees - children = getattr(c, 'children', []) - - # Create the dictionary representation return { - 'id': c.id, - 'name': c.name, - 'hadis_count': getattr(c, 'hadis_count', 0), - 'source_type': source_type, - 'category_type': category_type, - 'children': [] if not children else [self.to_dict(child) for child in children], + 'id': category.id, + 'title': get_localized_text(category.title, request), + 'description': get_localized_text(category.description, request), + 'slug': category.slug, + 'source_type': category.source_type, + 'hadis_count': category.hadis_count, # ← Use annotated value + 'children_count': category.children_count, # ← Use annotated value + 'has_hadis': category.hadis_count > 0, # ← Simple calculation + 'order': category.order, + 'thumbnail': self._get_thumbnail_url(category, request), + 'xmind_file': self._get_xmind_url(category, request), + 'has_xmind_file': bool(getattr(category, 'xmind_file', None)), + 'children': [ + self._build_tree(child, request) + for child in category.children.all() # Already prefetched! + ] } - def get_pagination_cache_key(self, source_type=None, page=1, page_size=None): - """ - Generate a cache key for paginated results. - - Args: - source_type: Optional source_type filter - page: Page number - page_size: Number of items per page - - Returns: - A unique cache key for the paginated results - """ - # Base key with the view name - key_parts = ['category_tree_paginated'] - - # Add filter parameters - if source_type: - key_parts.append(f'source_type:{source_type}') - - # Add pagination parameters - key_parts.append(f'page:{page}') - if page_size: - key_parts.append(f'page_size:{page_size}') - else: - key_parts.append(f'page_size:{self.pagination_class.page_size}') - - # Join all parts with a separator - key = ':'.join(key_parts) - - return key - - @category_list_swagger + def _count_tree_items(self, grouped_data): + """Count total items in tree""" + total = 0 + for sect_type_data in grouped_data.values(): + for item in sect_type_data['categories']: + total += 1 + total += self._count_children(item.get('children', [])) + return total + + def _count_children(self, children_list): + """Recursively count children""" + count = 0 + for item in children_list: + count += 1 + if item.get('children'): + count += self._count_children(item['children']) + return count + + def _get_thumbnail_url(self, category, request): + """Get absolute thumbnail URL""" + if hasattr(category, 'thumbnail') and category.thumbnail: + return request.build_absolute_uri(category.thumbnail.url) if request else category.thumbnail.url + return None + + def _get_xmind_url(self, category, request): + """Get absolute xmind URL""" + if getattr(category, 'xmind_file', None): + return request.build_absolute_uri(category.xmind_file.url) if request else category.xmind_file.url + return None + + +class HadisCategoryTreeNormalView(ListAPIView): + """ + Normal (paginated) tree view for HadisCategory. + Unlike the sync view, this simply returns the root categories (filtered to active sects) + with their nested children, and uses the project's default pagination. + """ + serializer_class = HadisCategoryTreeSerializer + + @hadis_category_tree_swagger def get(self, request, *args, **kwargs): - from mptt.templatetags.mptt_tags import cache_tree_children - - # Get source_type filter from query params - source_type = request.query_params.get('source_type', None) + return self.list(request, *args, **kwargs) - # Get pagination parameters - page = request.query_params.get('page', 1) - page_size = request.query_params.get('page_size', self.pagination_class.page_size) - - # Try to get paginated response from cache first - pagination_cache_key = self.get_pagination_cache_key(source_type, page, page_size) - cached_response = cache.get(pagination_cache_key) - - if cached_response: - return Response(cached_response) + def get_queryset(self): + return HadisCategory.objects.filter( + parent__isnull=True, + sect__is_active=True + ).order_by('sect__order', 'order') - # Generate a unique cache key for the full tree - tree_cache_key = self.get_cache_key(source_type) - # Try to get the tree from cache first - tree = cache.get(tree_cache_key) +class HadisCategorySelectBySectView(ListAPIView): + """ + Tree view for HadisCategory filtered by sect_type and category slug. + Returns the children categories of the specified category (by slug) within the sect_type. + """ + serializer_class = HadisCategorySelectSerializer - # If not in cache, build the tree - if tree is None: - # Build filter query - filter_query = Q(is_active=True) - if source_type and source_type in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]: - filter_query &= Q(source_type=source_type) + @categories_tree_by_sect_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) - # Get ALL categories with hadis count - this is important to include all levels - queryset = HadisCategory.objects.filter(filter_query).select_related( - 'parent', 'parent__parent' # Prefetch parent relationships for efficient access - ).annotate( - hadis_count=Count('hadis'), + def get_queryset(self): + sect_type = self.kwargs.get('sect_type') + slug = self.kwargs.get('slug') + print(slug) + print(sect_type) + + # Find the parent category by slug and sect_type + try: + parent_category = HadisCategory.objects.get( + slug=slug, + sect__sect_type=sect_type, + sect__is_active=True ) + except HadisCategory.DoesNotExist: + print('not ok') + return HadisCategory.objects.none() + + # Return children of this category, filtered as before + return HadisCategory.objects.filter( + parent=parent_category, + sect__sect_type=sect_type, + sect__is_active=True + ).order_by('order') + + +class HadisCategorySelectBySectSourceView(ListAPIView): + """ + Tree view for HadisCategory filtered by sect_type, category slug and source_type. + Returns the children categories of the specified category (by slug) within the sect_type, filtered by source_type. + """ + serializer_class = HadisCategorySelectSourceSerializer + + @categories_tree_by_sect_source_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) - # Use cache_tree_children to build the full tree structure - # This will properly set up the parent-child relationships for the entire tree - all_categories = cache_tree_children(queryset) - - # Filter to get only level 1 (root) categories as the starting point for our tree - root_categories = [category for category in all_categories if category.parent is None] - - # Build the tree - tree = [] - for c in root_categories: - # Convert to dictionary with proper tree structure based on level - tdata = self.to_dict(c) - - # Calculate total hadis_count including all children recursively - def calculate_total_hadis_count(node): - total = node['hadis_count'] - for child in node['children']: - total += calculate_total_hadis_count(child) - return total - - # Update the hadis_count to include all children - tdata['hadis_count'] = calculate_total_hadis_count(tdata) - - # Add to the result tree - tree.append(tdata) - - # Store the tree in cache - cache.set(tree_cache_key, tree, self.CACHE_TIMEOUT) - - # Apply pagination only to the root categories (level 1) - page_obj = self.paginate_queryset(tree) - - if page_obj is not None: - # Get paginated response - response = self.get_paginated_response(page_obj) + def get_queryset(self): + sect_type = self.kwargs.get('sect_type') + slug = self.kwargs.get('slug') + source_type = self.kwargs.get('source_type') + + # Find the parent category by slug and sect_type + try: + parent_category = HadisCategory.objects.get( + slug=slug, + sect__sect_type=sect_type, + sect__is_active=True + ) + except HadisCategory.DoesNotExist: + return HadisCategory.objects.none() + + # Return children of this category, filtered by source_type + return HadisCategory.objects.filter( + parent=parent_category, + sect__sect_type=sect_type, + sect__is_active=True, + source_type=source_type + ).order_by('order') + +class CategoriesView(ListAPIView): + """ + API view to list all HadisCategories + """ + queryset = HadisCategory.objects.all() + serializer_class = CategorySerializer + + @categories_list_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) - # Cache the paginated response - cache.set(pagination_cache_key, response.data, self.CACHE_TIMEOUT) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) - return response - # If pagination is not applied, return the full tree - return Response(tree) +class CategoriesBySectView(ListAPIView): + """ + API view to list HadisCategories filtered by sect_type + """ + serializer_class = CategorySerializer def get_queryset(self): - """ - Get the base queryset for the serializer. - This is used by DRF's default list() method if we don't override get(). - - Note: This method is not used directly in our implementation since we override get(), - but it's kept for completeness and API compatibility. - """ - source_type = self.request.query_params.get('source_type', None) - - # Build filter query - filter_query = Q(is_active=True) - if source_type and source_type in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]: - filter_query &= Q(source_type=source_type) - - # Get ALL categories with proper prefetching for efficiency - queryset = HadisCategory.objects.filter(filter_query).select_related( - 'parent', 'parent__parent' - ).prefetch_related( - 'children', 'children__children' # Prefetch two levels of children - ).annotate( - hadis_count=Count('hadis'), - ) - - # Filter to only return root categories (level 1) - queryset = queryset.filter(parent=None) + sect_type = self.kwargs.get('sect_type') + return HadisCategory.objects.filter(sect__sect_type=sect_type) - return queryset + @categories_by_sect_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index f0b285e..04a6d3d 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch @@ -73,3 +74,417 @@ class HadisDetailView(RetrieveAPIView): context.update({'request': self.request}) return context +======= +from rest_framework.generics import ListAPIView, RetrieveAPIView +from django.shortcuts import get_object_or_404 +from utils.pagination import NoPagination +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response +from django.db.models import Count +from django.db.models import Prefetch +from ..serializers.category import get_localized_text + +from ..models import HadisCategory, Hadis, HadisCollection,HadisTransmitter , HadisCorrection ,HadisReference, HadisStatus ,ReferenceImage +from ..serializers import HadisListSerializer, HadisBasicSerializer, HadisDetailSerializer, HadisCollectionListSerializer, HadisSyncSerializer,HadisCorrectionSerializer,HadisTransmitterListSerializer +from ..docs import arguments_filters_swagger ,hadis_list_swagger, hadis_detail_swagger, hadis_collections_swagger, hadis_sync_swagger, hadis_transmitters_swagger, hadis_corrections_swagger, hadis_basic_swagger, hadis_main_list_swagger + + +class HadisCollectionListView(ListAPIView): + """ + API view to list all hadis collections + """ + queryset = HadisCollection.objects.filter(status=True).order_by('order') + serializer_class = HadisCollectionListSerializer + pagination_class = NoPagination + + @hadis_collections_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class HadisSyncView(ListAPIView): + """ + API view to sync all hadis data for offline mode + """ + serializer_class = HadisSyncSerializer + pagination_class = NoPagination + + def get_queryset(self): + return ( + Hadis.objects + .filter(status=True) + .select_related('category', 'hadis_status') + .prefetch_related( + 'tags', + # 1. OPTIMIZED TRANSMITTERS + Prefetch( + 'transmitters', + queryset=HadisTransmitter.objects.select_related( + 'transmitter', + 'transmitter__reliability', # <--- Fixes N+1 for reliability text + 'narrator_layer' + ).order_by('order') + ), + # 2. OPTIMIZED REFERENCES + Prefetch( + 'references', + queryset=HadisReference.objects + .select_related('book_reference') + .prefetch_related( + 'book_reference__authors', + # <--- Fixes N+1 for Image ordering + Prefetch( + 'images', + queryset=ReferenceImage.objects.order_by('priority') + ) + ) + ), + 'hadiscorrection_set', + ) + .order_by('id') + ) + + @hadis_sync_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + + response_data = { + 'count': queryset.count(), + 'results': serializer.data + } + + return Response(response_data) + + +class HadisListView(ListAPIView): + """ + API view to list Hadis by category_id + """ + serializer_class = HadisListSerializer + + @hadis_list_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + category_slug = self.kwargs.get('category_slug') + if not HadisCategory.objects.filter(slug=category_slug).exists(): + return Hadis.objects.none() + + return Hadis.objects.filter( + category__slug=category_slug, + status=True + ).order_by('number').annotate( + # distinct=True is CRITICAL here. + # Without it, if 3 narrators are from "Layer 1", it counts as 3. + # With it, it counts as 1 (unique layer). + layer_count=Count('transmitters__narrator_layer', distinct=True) + ).select_related('category') + + +class HadisMainListView(ListAPIView): + """ + API view to list Hadis by category_id + """ + serializer_class = HadisListSerializer + pagination_class = PageNumberPagination + + @hadis_main_list_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + # queryset = Hadis.objects.select_related('category', 'hadis_status') + queryset = Hadis.objects.select_related('category__sect', 'hadis_status') + + # Get search parameters + search_query = self.request.query_params.get('search', None) + status_filter = self.request.query_params.get('status', None) + category_filter = self.request.query_params.get('category', None) + + # Apply search filter + if search_query: + queryset = self.apply_search_filter(queryset, search_query) + + # Apply status filter + if status_filter: + queryset = queryset.filter(hadis_status__title__icontains=status_filter) + + # Apply category filter + if category_filter: + queryset = queryset.filter(category__title__icontains=category_filter) + + return queryset + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + + # Apply pagination + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + paginated_response = self.get_paginated_response(serializer.data) + + # Get category titles + category_titles = self.get_category_titles(request) + + # Get status titles + status_titles = self.get_status_titles(request) + + # Modify the paginated response to include our custom data + response_data = paginated_response.data + # response_data['category_titles'] = self.get_cached_category_titles(request) + # response_data['status_titles'] = self.get_cached_status_titles(request) + + return Response(response_data) + + # Fallback for when pagination is disabled + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'count': queryset.count(), + 'results': serializer.data + }) + + return Response(response_data) + + def get_category_titles(self,request): + """Get list of category titles based on language""" + from ..models import HadisCategory + + categories = HadisCategory.objects.all() + category_titles = [] + + for category in categories: + title = get_localized_text(category.title,request) + category_titles.append(title) + + return category_titles + + def get_status_titles(self, request): + """Get list of status titles based on language""" + from ..models import HadisStatus + + statuses = HadisStatus.objects.all().order_by('order') + status_titles = [] + + for status in statuses: + title = get_localized_text(status.title,request) + status_titles.append(title) + return status_titles + + def apply_search_filter(self, queryset, search_query): + """ + Apply search filter across multiple fields including JSONFields. + Searches in: title, title_narrator, text, translation + """ + from django.db.models import Q + + # Basic search conditions + search_conditions = Q(text__icontains=search_query) + + # For JSONFields, search in the JSON string representation + # This will find matches in the "text" values within the JSON arrays + search_conditions |= Q(title__icontains=search_query) + search_conditions |= Q(title_narrator__icontains=search_query) + search_conditions |= Q(translation__icontains=search_query) + + return queryset.filter(search_conditions) + + #we add this later + + # def get_cached_category_titles(self, request): + # """Fetches categories, cached for 1 hour to reduce DB load""" + # lang = getattr(request, "LANGUAGE_CODE", "en") + # cache_key = f"hadis_meta_categories_{lang}" + + # data = cache.get(cache_key) + # if not data: + # # If not in cache, fetch from DB + # from ..models import HadisCategory + # categories = HadisCategory.objects.all().only('id', 'title') # Fetch only needed fields + + # # Build list + # data = [get_localized_text(c.title, request) for c in categories] + + # # Save to cache + # cache.set(cache_key, data, timeout=60 * 60) # 1 Hour + + # return data + + # def get_cached_status_titles(self, request): + # """Fetches statuses, cached for 1 hour""" + # lang = getattr(request, "LANGUAGE_CODE", "en") + # cache_key = f"hadis_meta_statuses_{lang}" + + # data = cache.get(cache_key) + # if not data: + # from ..models import HadisStatus + # statuses = HadisStatus.objects.all().order_by('order') + # data = [get_localized_text(s.title, request) for s in statuses] + # cache.set(cache_key, data, timeout=60 * 60) + + # return data + + +class HadisBasicView(RetrieveAPIView): + """ + API view to retrieve basic Hadis information by hadis_slug + """ + serializer_class = HadisBasicSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'hadis_slug' + + @hadis_basic_swagger + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def get_queryset(self): + return Hadis.objects.filter(status=True) + + +class HadisDetailView(RetrieveAPIView): + """ + API view to retrieve detailed Hadis information by hadis_slug (excluding transmitters and corrections) + """ + serializer_class = HadisDetailSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'hadis_slug' + + @hadis_detail_swagger + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def get_queryset(self): + return Hadis.objects.filter(status=True).select_related( + 'category', 'hadis_status' + ).prefetch_related( + 'tags', + 'references__book_reference__title', + 'references__book_reference__images', + 'references__book_reference__authors', + 'references__book_reference__id', + 'references__book_reference__description', + ) + +class HadisTransmittersView(RetrieveAPIView): + """ + Fetches a single Hadis but filters the nested Transmitters list + if a ?layer=slug param is provided. + """ + serializer_class = HadisTransmitterListSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'hadis_slug' + + @hadis_transmitters_swagger + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def get_queryset(self): + # 1. Get the filter param + layer_slug = self.request.query_params.get('layer') + + # 2. Build the query for the "Child" (Transmitters) + # We start with the base optimization (select_related) + transmitter_qs = HadisTransmitter.objects.select_related( + 'transmitter', + 'narrator_layer' + ).order_by('order') + + # 3. Apply the filter to the Child Query (if param exists) + if layer_slug: + # Assumes 'NarratorLayer' has a 'slug' field. + # If not, use 'narrator_layer__name' or 'narrator_layer__id'. + transmitter_qs = transmitter_qs.filter(narrator_layer__slug=layer_slug) + + # 4. Use the Prefetch object to inject this filtered list into the Parent + return Hadis.objects.filter(status=True).prefetch_related( + Prefetch('transmitters', queryset=transmitter_qs) + ) + +class HadisCorrectionsView(ListAPIView): + """ + API view to retrieve corrections for a specific hadis + """ + serializer_class = HadisCorrectionSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'hadis_slug' + + @hadis_corrections_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + # hadis = self.get_object() + # corrections_data = [] + + # for correction in hadis.hadiscorrection_set.all(): + # correction_info = { + # 'id': correction.id, + # 'title': correction.title, + # 'description': correction.description, + # 'translation': correction.translation + # } + # corrections_data.append(correction_info) + + # return Response({ + # 'hadis_id': hadis.id, + # 'corrections_count': len(corrections_data), + # 'corrections': corrections_data + # }) + + def get_queryset(self): + hadis_slug = self.kwargs.get('hadis_slug') + try: + hadis = Hadis.objects.get(slug=hadis_slug, status=True) + if not HadisCorrection.objects.filter(hadis=hadis).exists(): + return Hadis.objects.none() + return HadisCorrection.objects.filter(hadis=hadis) + except Hadis.DoesNotExist: + return HadisCorrection.objects.none() + + +class HadisFiltersView(ListAPIView): + """ + API view to return filter data for hadis + Returns statuses and categories for filtering + """ + pagination_class = NoPagination + + @arguments_filters_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + # This view doesn't need a queryset, it returns computed data + return Hadis.objects.none() + + def list(self, request, *args, **kwargs): + # Get statuses from HadisStatus model + statuses = [] + for status in HadisStatus.objects.all().order_by('order'): + title_text = get_localized_text(status.title, request) + if title_text and status.slug: + statuses.append({ + 'text': title_text, + 'slug': status.slug + }) + + # Get categories from HadisCategory model + categories = [] + for category in HadisCategory.objects.all().order_by('order'): + title_text = get_localized_text(category.title, request) + if title_text and category.slug: + categories.append({ + 'text': title_text, + 'slug': category.slug + }) + + response_data = { + 'statuses': statuses, + 'categories': categories + } + + return Response(response_data) +>>>>>>> develop diff --git a/apps/hadis/views/info.py b/apps/hadis/views/info.py new file mode 100644 index 0000000..1c7b480 --- /dev/null +++ b/apps/hadis/views/info.py @@ -0,0 +1,35 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + + +from ..models import HadisSect, BookReference, Transmitters ,Hadis ,HadisCategory +from ..docs import hadis_info_swagger +from apps.bookmark.models import Bookmark + + +class HadisInfoView(APIView): + """ + API view to get hadis statistics + """ + + @hadis_info_swagger + def get(self, request, *args, **kwargs): + hadis_count = Hadis.objects.all().count() + category_count = HadisCategory.objects.all().count() + reference_count = BookReference.objects.count() + bookmark_count = Bookmark.objects.filter( + service=Bookmark.ServiceChoices.HADITH, + status=True + ).count() + narrator_count = Transmitters.objects.count() + + data = { + 'hadis_count':hadis_count, + 'category_count': category_count, + 'reference_count': reference_count, + 'bookmark_count': bookmark_count, + 'narrator_count': narrator_count + } + + return Response(data, status=status.HTTP_200_OK) diff --git a/apps/hadis/views/reference.py b/apps/hadis/views/reference.py new file mode 100644 index 0000000..9de7a06 --- /dev/null +++ b/apps/hadis/views/reference.py @@ -0,0 +1,118 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView,ListCreateAPIView +from rest_framework.response import Response +from django.db.models import Q +from ..models import BookReference , BookAuthor , BookReferenceImage, BookAttribute +from ..serializers.reference import BookAuthorSerializer, BookDetailSerializer , BookReferenceSerializer, BookReferenceSyncSerializer, BookAttributeSerializer +from ..serializers.category import get_localized_text +from ..docs import book_attributes_create_swagger, book_attributes_list_swagger, book_references_list_swagger, book_authors_list_swagger, book_detail_swagger, reference_sync_swagger +from utils.pagination import NoPagination + + + +class BookReferencesView(ListAPIView): + queryset = BookReference.objects.all() + serializer_class = BookReferenceSerializer + + @book_references_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = BookReference.objects.all() + + # Get search parameter + search_query = self.request.query_params.get('search', None) + + # Apply search filter + if search_query: + queryset = self.apply_search_filter(queryset, search_query) + + return queryset + + def apply_search_filter(self, queryset, search_query): + """ + Apply search filter across book titles (JSONField). + Searches in: title + """ + # Search conditions + search_conditions = Q() + + # For JSONFields, search in the JSON string representation + # This will find matches in the "text" values within the JSON arrays + search_conditions |= Q(title__icontains=search_query) + + return queryset.filter(search_conditions) + + +class BookAuthorView(ListAPIView): + queryset = BookAuthor.objects.all() + serializer_class = BookAuthorSerializer + + @book_authors_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + +class BookDetailView(RetrieveAPIView): + serializer_class = BookDetailSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'reference_slug' + + @book_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return BookReference.objects.filter(slug = self.kwargs.get('reference_slug')) + # .prefetch_related( + # 'authors__name', + # 'images__image', + # ) + + +from django.db.models import Prefetch + +class BookReferenceSyncView(ListAPIView): + """ + API view to sync all book reference data for offline mode + Returns all book references with basic info, detailed information, and related hadises + """ + serializer_class = BookReferenceSyncSerializer + pagination_class = NoPagination + + @reference_sync_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + """ + Prefetch ALL related data to avoid N+1 queries + """ + return ( + BookReference.objects + .select_related() # If any ForeignKey fields + .prefetch_related( + 'authors', + 'attributes', # ← ADDED + 'images', # ← ADDED + 'hadis_references__hadis' + ) + .order_by('id') + ) + + +class BookAttributeView(ListCreateAPIView): + """ + API view to list all book attributes and create new book attributes + """ + queryset = BookAttribute.objects.all() + serializer_class = BookAttributeSerializer + + @book_attributes_list_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @book_attributes_create_swagger + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + diff --git a/apps/hadis/views/transmitter.py b/apps/hadis/views/transmitter.py new file mode 100644 index 0000000..86f54da --- /dev/null +++ b/apps/hadis/views/transmitter.py @@ -0,0 +1,176 @@ +from django.contrib.admin.utils import lookup_field +from django.db.models import Prefetch +from rest_framework.generics import ListAPIView , RetrieveAPIView +from rest_framework.response import Response + + +from ..models import Transmitters , TransmitterOpinion, TransmitterOriginalText +from ..serializers import TransmitterSerializer , TransmitterDetailSerializer, TransmitterSyncSerializer,TransmitterOpinionSerializer, TransmitterOriginalTextSerializer +from ..docs import transmitter_filters_swagger,transmitter_list_swagger, transmitter_detail_swagger, transmitter_sync_swagger, transmitter_opinion_swagger, transmitter_original_text_swagger +from utils.pagination import NoPagination +from .category import get_localized_text + +class TransmitterFliterView(ListAPIView): + pass + +class TransmitterView(ListAPIView): + queryset = Transmitters.objects.all() + serializer_class = TransmitterSerializer + + @transmitter_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = Transmitters.objects.all() + + status_filter = self.request.query_params.get('status', None) + if status_filter: + queryset = queryset.filter(reliability=status_filter) + + madhhab_filter = self.request.query_params.get('madhhab', None) + if madhhab_filter: + queryset = queryset.filter(madhhab=madhhab_filter) + + generation_filter = self.request.query_params.get('generation', None) + if generation_filter: + queryset = queryset.filter(generation=generation_filter) + + return queryset + +class TransmitterDetailView(RetrieveAPIView): + serializer_class = TransmitterDetailSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'narrator_slug' + + @transmitter_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + input = self.kwargs.get('narrator_slug') + return Transmitters.objects.filter(slug=input) + +class TransmitterOpinionView(ListAPIView): + serializer_class = TransmitterOpinionSerializer + + @transmitter_opinion_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + narrator_slug = self.kwargs.get('narrator_slug') + return TransmitterOpinion.objects.filter( + transmitter__slug=narrator_slug + ).select_related( + 'transmitter', # Essential if serializer includes transmitter data + 'status' # Added for the new OpinionStatus ForeignKey + ).prefetch_related( + # Add any nested relations from TransmitterOriginalTextSerializer + # 'translations', + # 'manuscript_references', + ) + + +class TransmitterOriginalTextView(ListAPIView): + serializer_class = TransmitterOriginalTextSerializer + + @transmitter_original_text_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + narrator_slug = self.kwargs.get('narrator_slug') + + return TransmitterOriginalText.objects.filter( + transmitter__slug=narrator_slug + ).select_related( + 'transmitter' # Essential if serializer includes transmitter data + ).prefetch_related( + # Add any nested relations from TransmitterOriginalTextSerializer + # 'translations', + # 'manuscript_references', + ) + + + +class TransmitterSyncView(ListAPIView): + """ + API view to sync all transmitter data for offline mode + Returns all transmitters with biographical data and scholarly opinions + """ + serializer_class = TransmitterSyncSerializer + pagination_class = NoPagination + + @transmitter_sync_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + """ + Prefetch ALL related data at once + """ + return ( + Transmitters.objects + .prefetch_related( + Prefetch('opinions', queryset=TransmitterOpinion.objects.select_related('status')), + Prefetch('originaltexts', queryset=TransmitterOriginalText.objects.all()) + ) + .order_by('id') + ) + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True, context={'request': request}) + + response_data = { + 'count': len(serializer.data), # ← No extra query! + 'results': serializer.data + } + + return Response(response_data) + + +class TransmitterFiltersView(ListAPIView): + """ + API view to return filter data for transmitters + Returns generations, madhabs, and reliabilities for filtering + """ + pagination_class = NoPagination + + @transmitter_filters_swagger + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + # This view doesn't need a queryset, it returns computed data + return Transmitters.objects.none() + + def list(self, request, *args, **kwargs): + # Get all unique generations + generations = Transmitters.objects.exclude(generation__isnull=True).values_list('generation', flat=True).distinct().order_by('generation') + + # Get madhhab choices + madhabs = [] + for choice in Transmitters.MadhhabChoices: + madhabs.append(choice.value) + + # Get reliabilities from TransmitterReliability model + from ..models.transmitter import TransmitterReliability + reliabilities = [] + for reliability in TransmitterReliability.objects.all().order_by('id'): + # Get the English title for the slug + if reliability.title: + reliabilities.append({ + 'text': get_localized_text(reliability.title,request), + 'slug': reliability.slug + }) + + response_data = { + 'generations': list(generations), + 'madhabs': madhabs, + 'reliabilities': reliabilities + } + + return Response(response_data) + diff --git a/apps/hadis/views/version.py b/apps/hadis/views/version.py new file mode 100644 index 0000000..c8d0dab --- /dev/null +++ b/apps/hadis/views/version.py @@ -0,0 +1,20 @@ +from rest_framework.generics import RetrieveAPIView +from rest_framework.response import Response + +from ..models import ContentRelease +from ..serializers import ContentReleaseSyncSerializer +from ..docs import content_release_sync_swagger + + +class ContentReleaseSyncView(RetrieveAPIView): + """ + API view to get the latest content release for offline mode sync + """ + serializer_class = ContentReleaseSyncSerializer + + @content_release_sync_swagger + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def get_object(self): + return ContentRelease.objects.filter(is_active=True).order_by('-published_at').first() diff --git a/apps/library/admin.py b/apps/library/admin.py index 52e1380..db85697 100644 --- a/apps/library/admin.py +++ b/apps/library/admin.py @@ -3,43 +3,93 @@ from django.utils.translation import gettext_lazy as _ from django.urls import reverse from django.utils.html import format_html from ajaxdatatable.admin import AjaxDatatable +from unfold.admin import ModelAdmin +from django.contrib.admin import SimpleListFilter +from unfold.decorators import display, action +from django import forms +from utils.admin import dovoodi_admin_site from apps.library.models import * -@admin.register(Book) -class BookAdmin(AjaxDatatable): - list_display = ('title', 'slug', 'status', 'pin', 'file_type', 'view_count', 'created_at') +class BookCollectionAdmin(ModelAdmin): + list_display = ('title', 'display_position', 'status', 'order') + list_filter = ('status', 'display_position') + search_fields = ('title',) + +dovoodi_admin_site.register(BookCollection, BookCollectionAdmin) + +class BookAdminForm(forms.ModelForm): + class Meta: + model = Book + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Make thumbnail field required in the form + self.fields['thumbnail'].required = True + +class BookAdmin(ModelAdmin): + form = BookAdminForm + list_display = ('title', 'display_categories', 'display_collections', 'status', 'view_count', 'created_at') list_filter = ('status', 'pin', 'file_type', 'created_at', 'updated_at') search_fields = ('title', 'slug', 'summary', 'description') # autocomplete_fields = ('categories', 'collections', ) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + + save_as = True + + @display(description=_('Categories')) + def display_categories(self, obj): + categories = obj.categories.all() + if categories: + return ', '.join([category.title for category in categories]) + return '-' + + @display(description=_('Collections')) + def display_collections(self, obj): + collections = obj.collections.all() + if collections: + return ', '.join([collection.title for collection in collections]) + return '-' + fieldsets = ( (None, { - 'fields': ('title', 'slug', 'summary', 'description', 'thumbnail', 'pages_count') + 'fields': () + }), + ('Detail', { + 'fields': ('title', 'slogan', 'thumbnail', 'pages_count', 'publisher', 'year_of_publication', 'isbn', 'numnber_of_volume') }), + ("Summary", { + 'fields': ('summary_title', 'summary') + }), (_('Status'), { 'fields': ('status', 'pin') }), (_('File Information'), { - 'fields': ('file_type', 'book_file') + 'fields': ('file_type', 'book_file', ) }), (_('Relations'), { 'fields': ('categories', 'collections') }), (_('Statistics'), { - 'fields': ('view_count',) + 'fields': ('view_count', 'download_count') }), ) - - -class BookCollectionAdminBase(AjaxDatatable): +class BookCollectionAdminBase(ModelAdmin): """Base admin class for all book collection types""" list_display = ('get_title', 'status', 'order', 'count_books') - list_filter = ('status',) + list_filter = ('status', 'order') search_fields = ('title',) autocomplete_fields = ('books',) ordering = ('order',) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + fieldsets = ( (None, { @@ -52,27 +102,48 @@ class BookCollectionAdminBase(AjaxDatatable): exclude = ('display_position',) + @display(description=_('Title')) def get_title(self, obj): return str(obj.title) - get_title.short_description = _('Title') - - @admin.display(description=_('Number of Books')) + # @display(description=_("Status"), ordering="status") + # def status_badge(self, obj): + # if obj.status: + # return format_html( + # '{}', + # _("Active") + # ) + # return format_html( + # '{}', + # _("Inactive") + # ) + + @display(description=_('Number of Books')) def count_books(self, obj): count = obj.books.count() if count > 0: url = reverse('admin:library_book_changelist') + f'?collections__id__exact={obj.id}' return format_html('{}', url, count) return count + + - -@admin.register(PinnedBookCollection) class PinnedBookCollectionAdmin(BookCollectionAdminBase): """Admin for pinned book collections only""" + list_before_template = "admin/library/pinnedbookcollection/change_list_before.html" + + fieldsets = ( + (None, { + 'fields': ('title', 'summary', 'status', 'order', 'pin_top') + }), + (_('Books'), { + 'fields': ('books',) + }), + ) def get_queryset(self, request): # Only show pinned collections @@ -84,7 +155,6 @@ class PinnedBookCollectionAdmin(BookCollectionAdminBase): super().save_model(request, obj, form, change) -@admin.register(MiddleBookCollection) class MiddleBookCollectionAdmin(BookCollectionAdminBase): """Admin for middle section book collections only""" @@ -92,101 +162,89 @@ class MiddleBookCollectionAdmin(BookCollectionAdminBase): # Only show middle section collections return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.MIDDLE) - def has_add_permission(self, request): - # Check if a middle collection already exists - exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists() - # Only allow adding if no middle collection exists - return not exists + # def has_add_permission(self, request): + # # Check if a middle collection already exists + # exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists() + # # Only allow adding if no middle collection exists + # return not exists - def has_delete_permission(self, request, obj=None): - # Prevent deletion of the middle collection - return False + # def has_delete_permission(self, request, obj=None): + # # Prevent deletion of the middle collection + # return False def save_model(self, request, obj, form, change): # Ensure the display_position is always set to MIDDLE obj.display_position = BookCollection.DisplayPosition.MIDDLE super().save_model(request, obj, form, change) - def changelist_view(self, request, extra_context=None): - # Check if a middle collection exists - try: - # Try to get the first (and should be only) middle collection - obj = self.get_queryset(request).first() - if obj: - # If it exists, redirect to the change view for this object - from django.http import HttpResponseRedirect - from django.urls import reverse - url = reverse( - 'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), - args=[obj.pk] - ) - return HttpResponseRedirect(url) - except Exception: - # If any error occurs, just show the changelist view as usual - pass - - # If no object exists or there was an error, show the default changelist view - return super().changelist_view(request, extra_context) - - -@admin.register(BottomBookCollection) -class BottomBookCollectionAdmin(BookCollectionAdminBase): - """Admin for bottom section book collections only""" - - def get_queryset(self, request): - # Only show bottom section collections - return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.BOTTOM) - def has_add_permission(self, request): - # Check if a bottom collection already exists - exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.BOTTOM).exists() - # Only allow adding if no bottom collection exists - return not exists - def has_delete_permission(self, request, obj=None): - # Prevent deletion of the bottom collection - return False - def save_model(self, request, obj, form, change): - # Ensure the display_position is always set to BOTTOM - obj.display_position = BookCollection.DisplayPosition.BOTTOM - super().save_model(request, obj, form, change) - - def changelist_view(self, request, extra_context=None): - # Check if a bottom collection exists - try: - # Try to get the first (and should be only) bottom collection - obj = self.get_queryset(request).first() - if obj: - # If it exists, redirect to the change view for this object - from django.http import HttpResponseRedirect - from django.urls import reverse - url = reverse( - 'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), - args=[obj.pk] - ) - return HttpResponseRedirect(url) - except Exception: - # If any error occurs, just show the changelist view as usual - pass - - # If no object exists or there was an error, show the default changelist view - return super().changelist_view(request, extra_context) - - - -@admin.register(Category) -class CategoryAdmin(AjaxDatatable): - list_display = ('title', 'slug', 'status', 'count_books', 'created_at') +class CategoryAdmin(ModelAdmin): + list_display = ('title', 'slug', 'status_badge', 'count_books', 'created_at') list_filter = ('status', 'created_at', 'updated_at') search_fields = ('title', 'slug') - # autocomplete_fields = ('books',) - - @admin.display(description=_('Number of Books')) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + + # Custom actions + actions_list = ['mark_as_active', 'mark_as_inactive'] + actions_row = ['toggle_status'] + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'status') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',), + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + @display(description=_("Status"), ordering="status") + def status_badge(self, obj): + if obj.status: + return format_html( + '{}', + _("Active") + ) + return format_html( + '{}', + _("Inactive") + ) + + @display(description=_('Number of Books')) def count_books(self, obj): count = obj.books_count if count > 0: url = reverse('admin:library_book_changelist') + f'?categories__id__exact={obj.id}' return format_html('{}', url, count) return count + + @action(description=_("Mark selected categories as active")) + def mark_as_active(self, request, queryset): + updated = queryset.update(status=True) + self.message_user(request, _("%(count)d categories were successfully marked as active.") % {"count": updated}) + + @action(description=_("Mark selected categories as inactive")) + def mark_as_inactive(self, request, queryset): + updated = queryset.update(status=False) + self.message_user(request, _("%(count)d categories were successfully marked as inactive.") % {"count": updated}) + + @action(description=_("Toggle status")) + def toggle_status(self, request, obj): + obj.status = not obj.status + obj.save(update_fields=["status"]) + status_text = _("active") if obj.status else _("inactive") + self.message_user(request, _("Category '%(title)s' is now %(status)s.") % {"title": obj.title, "status": status_text}) + + +# Register models with the custom admin site +dovoodi_admin_site.register(Book, BookAdmin) +dovoodi_admin_site.register(PinnedBookCollection, PinnedBookCollectionAdmin) +dovoodi_admin_site.register(MiddleBookCollection, MiddleBookCollectionAdmin) +dovoodi_admin_site.register(Category, CategoryAdmin) diff --git a/apps/library/doc.py b/apps/library/doc.py index 0c932a8..5013d39 100644 --- a/apps/library/doc.py +++ b/apps/library/doc.py @@ -33,10 +33,34 @@ bottom_param = openapi.Parameter( required=False ) +is_bookmark_param = openapi.Parameter( + 'is_bookmark', + openapi.IN_QUERY, + description="Filter books that are bookmarked by the current user (set to 'true' to enable)", + type=openapi.TYPE_BOOLEAN, + required=False +) + +category_param = openapi.Parameter( + 'category', + openapi.IN_QUERY, + description="Filter books by category slug(s). Can be a single slug or comma-separated list of slugs", + type=openapi.TYPE_STRING, + required=False +) + +sort_param = openapi.Parameter( + 'sort', + openapi.IN_QUERY, + description="Sort books by field. Options: created_at, -created_at, view_count, -view_count, download_count, -download_count, title, -title, pin, -pin, -pin,-created_at", + type=openapi.TYPE_STRING, + required=False +) + search_param = openapi.Parameter( 'search', openapi.IN_QUERY, - description="Search books by title, summary, or author", + description="Search books by title, summary, publisher, or isbn", type=openapi.TYPE_STRING, required=False ) @@ -74,6 +98,21 @@ book_schema = openapi.Schema( type=openapi.TYPE_STRING, description="Author of the book" ), + 'language': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Language ID of the book", + nullable=True + ), + 'main_themes': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_STRING), + description="List of main themes" + ), + 'notable_works': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_STRING), + description="List of notable works" + ), 'status': openapi.Schema( type=openapi.TYPE_BOOLEAN, description="Whether the book is active/visible" @@ -150,7 +189,7 @@ book_detail_swagger = swagger_auto_schema( The book is specified by its ID in the URL path. """, operation_summary="Get Book Detail", - tags=["Library"], + tags=["Dobodbi - Library"], responses={ 200: book_detail_response, 401: "Authentication credentials were not provided or are invalid.", @@ -170,14 +209,23 @@ book_list_swagger = swagger_auto_schema( You can filter books by: - Collection ID using the query parameter 'collection_id' - - Middle section collection using the query parameter 'middle' - - Bottom section collection using the query parameter 'bottom' + - Category slug(s) using the query parameter 'category' (single slug or comma-separated list) + - Bookmarked books using the query parameter 'is_bookmark=true' + + You can also search for books by title, summary, publisher, or isbn using the query parameter 'search'. - You can also search for books by title, summary, or author using the query parameter 'search'. + You can sort books by: + - created_at, -created_at + - view_count, -view_count + - download_count, -download_count + - title, -title + - pin, -pin, -pin,-created_at + + Note: To get downloaded books, use the separate endpoint /books/downloaded/ """, operation_summary="List Books", - tags=["Library"], - manual_parameters=[collection_id_param, middle_param, bottom_param, search_param], + tags=["Dobodbi - Library"], + manual_parameters=[collection_id_param, category_param, is_bookmark_param, search_param, sort_param], responses={ 200: books_response, 401: "Authentication credentials were not provided or are invalid.", @@ -194,7 +242,7 @@ category_list_swagger = swagger_auto_schema( title, slug, status, books count, and timestamps. """, operation_summary="List Book Categories", - tags=["Library"], + tags=["Dobodbi - Library"], responses={ 200: "List of book categories", 401: "Authentication credentials were not provided or are invalid.", @@ -211,10 +259,30 @@ pinned_collection_list_swagger = swagger_auto_schema( title and the covers of its top books by view count. """, operation_summary="List Pinned Book Collections", - tags=["Library"], + tags=["Dobodbi - Library"], responses={ 200: "List of pinned book collections with covers", 401: "Authentication credentials were not provided or are invalid.", 500: "Internal server error occurred." } +) + +middle_collection_list_swagger = swagger_auto_schema( + operation_id="list_middle_collections", + operation_description=""" + Retrieve a list of middle section book collections with their books. + + This endpoint returns a list of middle section book collections. Each collection includes its + title, slug, summary, status, order, and a list of books in the collection. + + Each book in the collection includes its id, title, slug, summary, thumbnail, author, + view count, download count, and file type. + """, + operation_summary="List Middle Section Book Collections", + tags=["Dobodbi - Library"], + responses={ + 200: "List of middle section book collections with their books", + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } ) \ No newline at end of file diff --git a/apps/library/migrations/0001_initial.py b/apps/library/migrations/0001_initial.py index 6d16c23..a9264ca 100644 --- a/apps/library/migrations/0001_initial.py +++ b/apps/library/migrations/0001_initial.py @@ -1,9 +1,18 @@ +<<<<<<< HEAD # Generated by Django 3.2.7 on 2025-03-20 07:06 from django.conf import settings from django.db import migrations, models import django.db.models.deletion import filer.fields.image +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.image +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -11,12 +20,16 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD migrations.swappable_dependency(settings.AUTH_USER_MODEL), +======= +>>>>>>> develop migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), ] operations = [ migrations.CreateModel( +<<<<<<< HEAD name='Book', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -39,6 +52,8 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +======= +>>>>>>> develop name='Category', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -47,7 +62,10 @@ class Migration(migrations.Migration): ('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')), +<<<<<<< HEAD ('books', models.ManyToManyField(blank=True, related_name='related_categories_books', to='library.Book', verbose_name='Books')), +======= +>>>>>>> develop ], options={ 'verbose_name': 'Category', @@ -55,6 +73,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +<<<<<<< HEAD name='BookDownload', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -65,18 +84,50 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'Book Download', 'verbose_name_plural': 'Book Downloads', +======= + name='Book', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(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)), + ('description', models.TextField(blank=True, help_text='could be null', null=True)), + ('author', models.CharField(blank=True, max_length=255, null=True)), + ('pages_count', models.CharField(help_text='eg. 34', max_length=255, null=True, verbose_name='Number of Pages')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('pin', models.BooleanField(default=True, verbose_name='Pin to top')), + ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('download_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('file_type', models.CharField(choices=[('pdf', 'Pdf'), ('epub', 'Epub'), ('docx', 'Docx')], default='pdf', max_length=16, verbose_name='File Type')), + ('book_file', models.FileField(blank=True, max_length=550, null=True, upload_to='books', verbose_name='Book File')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL)), + ], + options={ + 'verbose_name': 'Book', + 'verbose_name_plural': 'Books', +>>>>>>> develop }, ), migrations.CreateModel( name='BookCollection', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +<<<<<<< HEAD ('title', models.JSONField(default=dict, verbose_name='title')), +======= + ('title', models.CharField(max_length=255)), +>>>>>>> develop ('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)), ('display_position', models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section'), ('bottom', 'Bottom Section')], default='pinned', max_length=20, verbose_name='Display Position')), ('status', models.BooleanField(default=True, verbose_name='status')), ('order', models.IntegerField(default=0, verbose_name='order')), +<<<<<<< HEAD ('books', models.ManyToManyField(blank=True, related_name='related_collections_books', to='library.Book', verbose_name='Books')), +======= + ('books', models.ManyToManyField(blank=True, related_name='related_collections_books', to='library.book', verbose_name='Books')), +>>>>>>> develop ], options={ 'verbose_name': 'Book Collection', @@ -85,6 +136,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='book', +<<<<<<< HEAD name='categories', field=models.ManyToManyField(blank=True, related_name='related_categories', to='library.Category', verbose_name='categories'), ), @@ -97,6 +149,10 @@ class Migration(migrations.Migration): model_name='book', name='thumbnail', field=filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL), +======= + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_collections', to='library.bookcollection', verbose_name='collections'), +>>>>>>> develop ), migrations.CreateModel( name='BottomBookCollection', @@ -137,4 +193,12 @@ class Migration(migrations.Migration): }, bases=('library.bookcollection',), ), +<<<<<<< HEAD +======= + migrations.AddField( + model_name='book', + name='categories', + field=models.ManyToManyField(blank=True, related_name='related_categories', to='library.category', verbose_name='categories'), + ), +>>>>>>> develop ] diff --git a/apps/library/migrations/0002_alter_book_thumbnail.py b/apps/library/migrations/0002_alter_book_thumbnail.py new file mode 100644 index 0000000..c4a6803 --- /dev/null +++ b/apps/library/migrations/0002_alter_book_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='thumbnail', + field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='book_thumbnails/'), + ), + ] diff --git a/apps/library/migrations/0003_bookcollection_pin_top.py b/apps/library/migrations/0003_bookcollection_pin_top.py new file mode 100644 index 0000000..1987d92 --- /dev/null +++ b/apps/library/migrations/0003_bookcollection_pin_top.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-15 01:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0002_alter_book_thumbnail'), + ] + + operations = [ + migrations.AddField( + model_name='bookcollection', + name='pin_top', + field=models.BooleanField(default=True, verbose_name='pin top'), + ), + ] diff --git a/apps/library/migrations/0004_bookcollection_slug.py b/apps/library/migrations/0004_bookcollection_slug.py new file mode 100644 index 0000000..c1a17cc --- /dev/null +++ b/apps/library/migrations/0004_bookcollection_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.8 on 2025-04-15 01:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0003_bookcollection_pin_top'), + ] + + operations = [ + migrations.AddField( + model_name='bookcollection', + name='slug', + field=models.SlugField(default='1', max_length=255, unique=True), + preserve_default=False, + ), + ] diff --git a/apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py b/apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py new file mode 100644 index 0000000..3263687 --- /dev/null +++ b/apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py @@ -0,0 +1,40 @@ +# 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): + + dependencies = [ + ('library', '0004_bookcollection_slug'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BookDownload', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='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')), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='library.book', verbose_name='book')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='book_downloads', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'Book Download', + 'verbose_name_plural': 'Book Downloads', + 'ordering': ('-created_at',), + }, + ), + migrations.DeleteModel( + name='BottomBookCollection', + ), + migrations.AlterField( + model_name='bookcollection', + name='display_position', + field=models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position'), + ), + ] diff --git a/apps/library/migrations/0006_remove_book_author_book_isbn_book_numnber_of_volume_and_more.py b/apps/library/migrations/0006_remove_book_author_book_isbn_book_numnber_of_volume_and_more.py new file mode 100644 index 0000000..5a6c7ad --- /dev/null +++ b/apps/library/migrations/0006_remove_book_author_book_isbn_book_numnber_of_volume_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.8 on 2025-05-04 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0005_bookdownload_delete_bottombookcollection_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='book', + name='author', + ), + migrations.AddField( + model_name='book', + name='isbn', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='numnber_of_volume', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='publisher', + field=models.CharField(blank=True, max_length=655, null=True), + ), + migrations.AddField( + model_name='book', + name='slogan', + field=models.CharField(blank=True, max_length=300, null=True), + ), + migrations.AddField( + model_name='book', + name='summary_title', + field=models.CharField(blank=True, help_text='Summary Title', max_length=512, null=True), + ), + migrations.AddField( + model_name='book', + name='year_of_publication', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='summary', + field=models.CharField(blank=True, help_text='Summary', max_length=512, null=True), + ), + ] diff --git a/apps/library/migrations/0007_auto_20251203_1529.py b/apps/library/migrations/0007_auto_20251203_1529.py new file mode 100644 index 0000000..673b452 --- /dev/null +++ b/apps/library/migrations/0007_auto_20251203_1529.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-12-03 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0006_remove_book_author_book_isbn_book_numnber_of_volume_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='author', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/library/migrations/0008_auto_20251203_1533.py b/apps/library/migrations/0008_auto_20251203_1533.py new file mode 100644 index 0000000..532440e --- /dev/null +++ b/apps/library/migrations/0008_auto_20251203_1533.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.8 on 2025-12-03 15:33 + +from django.db import migrations, models +import dj_language.field + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0007_auto_20251203_1529'), + ('dj_language', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='language', + field=dj_language.field.LanguageField(blank=True, null=True, on_delete=models.SET_NULL, to='dj_language.language', verbose_name='Language'), + ), + migrations.AddField( + model_name='book', + name='main_themes', + field=models.JSONField(blank=True, default=list, help_text='List of main themes', verbose_name='Main Themes'), + ), + migrations.AddField( + model_name='book', + name='notable_works', + field=models.JSONField(blank=True, default=list, help_text='List of notable works', verbose_name='Notable Works'), + ), + ] diff --git a/apps/library/migrations/0009_alter_book_language.py b/apps/library/migrations/0009_alter_book_language.py new file mode 100644 index 0000000..e525a47 --- /dev/null +++ b/apps/library/migrations/0009_alter_book_language.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.8 on 2025-12-03 23:32 + +import dj_language.field +import django.db.models.deletion +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dj_language', '0002_auto_20220120_1344'), + ('library', '0008_auto_20251203_1533'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='language', + field=dj_language.field.LanguageField(blank=True, 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'), + ), + ] diff --git a/apps/library/models.py b/apps/library/models.py index 0a16b22..4476958 100644 --- a/apps/library/models.py +++ b/apps/library/models.py @@ -3,16 +3,21 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField +from dj_language.field import LanguageField +from utils import generate_slug_for_model +from apps.account.models import User class BookCollection(models.Model): class DisplayPosition(models.TextChoices): PINNED = 'pinned', _('Pinned') MIDDLE = 'middle', _('Middle Section') - BOTTOM = 'bottom', _('Bottom Section') + # BOTTOM = 'bottom', _('Bottom Section') title = models.CharField(max_length=255) + 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) display_position = models.CharField( max_length=20, choices=DisplayPosition.choices, @@ -30,6 +35,12 @@ class BookCollection(models.Model): verbose_name = _('Book Collection') verbose_name_plural = _('Book Collections') + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(BookCollection, self.title) + super().save(*args, **kwargs) + + class PinnedBookCollection(BookCollection): """ @@ -51,14 +62,6 @@ class MiddleBookCollection(BookCollection): verbose_name_plural = _('Middle Section Book Collections') -class BottomBookCollection(BookCollection): - """ - Proxy model for bottom section book collections - """ - class Meta: - proxy = True - verbose_name = _('Bottom Section Book Collection') - verbose_name_plural = _('Bottom Section Book Collections') class Category(models.Model): @@ -73,6 +76,11 @@ class Category(models.Model): def __str__(self): return self.title + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(Category, self.title) + super().save(*args, **kwargs) + @property def books_count(self): """Return the number of books in this category""" @@ -91,13 +99,25 @@ class Book(models.Model): title = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True) - - summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null')) + slogan = models.CharField(max_length=300, blank=True, null=True) + + summary_title = models.CharField(max_length=512, null=True, blank=True, help_text=_('Summary Title')) + summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('Summary')) + description = models.TextField(null=True, blank=True, help_text=_('could be null')) - thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_( - 'image allowed' - )) + thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed')) + + publisher = models.CharField(max_length=655, null=True, blank=True) + year_of_publication = models.CharField(max_length=255, null=True, blank=True) author = models.CharField(max_length=255, null=True, blank=True) + isbn = models.CharField(max_length=255, null=True, blank=True) + numnber_of_volume = models.CharField(max_length=255, null=True, blank=True) + + # Language, themes and notable works + language = LanguageField(verbose_name=_('Language'), null=True, blank=True) + main_themes = models.JSONField(verbose_name=_('Main Themes'), default=list, blank=True, help_text=_('List of main themes')) + notable_works = models.JSONField(verbose_name=_('Notable Works'), default=list, blank=True, help_text=_('List of notable works')) + pages_count = models.CharField(verbose_name=_('Number of Pages'), max_length=255, help_text=_('eg. 34'), null=True) status = models.BooleanField(default=True, verbose_name=_('status')) pin = models.BooleanField(default=True, verbose_name=_('Pin to top')) @@ -119,6 +139,12 @@ class Book(models.Model): def __str__(self): return f'<{self.id}>-{self.title}' + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(Book, self.title) + super().save(*args, **kwargs) + + def increment_view_count(self): """Increment the view count by 1 and save the model""" self.view_count += 1 @@ -130,3 +156,22 @@ class Book(models.Model): verbose_name_plural = _('Books') +class BookDownload(models.Model): + """ + Model to track book downloads by users + """ + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='book_downloads', verbose_name=_('user')) + book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='downloads', verbose_name=_('book')) + 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 = _('Book Download') + verbose_name_plural = _('Book Downloads') + ordering = ('-created_at',) + + def __str__(self): + return f"{self.user} - {self.book}" + + diff --git a/apps/library/pagination.py b/apps/library/pagination.py new file mode 100644 index 0000000..10b254b --- /dev/null +++ b/apps/library/pagination.py @@ -0,0 +1,23 @@ + + +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + + +class NoPagination(PageNumberPagination): + def paginate_queryset(self, queryset, request, view=None): + # Override to return all items instead of paginated ones + self.count = len(queryset) + self.request = request + self.page = None + self.page_size = len(queryset) + return list(queryset) + + def get_paginated_response(self, data): + # Keep the structure but include all results + return Response({ + 'count': self.count, + 'next': None, # No next page + 'previous': None, # No previous page + 'results': data, + }) \ No newline at end of file diff --git a/apps/library/serializers.py b/apps/library/serializers.py index 016a941..3bbd835 100644 --- a/apps/library/serializers.py +++ b/apps/library/serializers.py @@ -1,21 +1,28 @@ -from dj_filer.admin import get_thumbs +from utils import get_thumbs from django.db.models import Avg, Q from rest_framework import serializers from apps.library.models import * +from apps.bookmark.serializers import * class CategorySerializer(serializers.ModelSerializer): - books_count = serializers.IntegerField(read_only=True) + books_count = serializers.SerializerMethodField() class Meta: model = Category fields = ('id', 'title', 'slug', 'status', 'books_count', 'created_at', 'updated_at') + + def get_books_count(self, obj): + # Use the annotation if available, otherwise fall back to the property + if hasattr(obj, 'books_count_annotation'): + return obj.books_count_annotation + return obj.books_count class PinnedBookCollectionSerializer(serializers.ModelSerializer): @@ -35,11 +42,14 @@ class PinnedBookCollectionSerializer(serializers.ModelSerializer): class Meta: model = BookCollection - fields = ('id', 'title', 'covers') + fields = ('id', 'title', 'summary', 'covers') class BookSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() + user_rate = serializers.SerializerMethodField() + average_rate = serializers.SerializerMethodField() def get_thumbnail(self, obj): if obj.thumbnail: @@ -49,9 +59,116 @@ class BookSerializer(serializers.ModelSerializer): class Meta: model = Book fields = ( - 'id', 'title', 'slug', 'summary', 'description', 'thumbnail', - 'author', 'status', 'pin', 'view_count', 'download_count', - 'file_type', 'book_file', 'created_at' + 'id', 'title', 'slug', 'summary', 'summary_title', 'thumbnail', 'slogan', + 'status', 'pin', 'view_count', 'download_count', 'publisher', 'year_of_publication', 'author', 'isbn', 'numnber_of_volume', + 'language', 'main_themes', 'notable_works', + 'file_type', 'book_file', 'created_at', 'bookmark', 'user_rate', + 'average_rate' ) + def get_bookmark(self, obj): + """ + Get bookmark information for this book. + """ + # 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='library' + ) + return book_mark.get('is_bookmarked', False) + + def get_user_rate(self, obj): + """ + Get rate information for this book 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='library', + content_id=obj.id + ) + + return rate_info + + def get_average_rate(self, obj): + """ + Get the average rate for this book. + """ + from apps.bookmark.models.rate import Rate + + # Get average rate using the Rate model's method + avg_rate = Rate.get_average_rate( + service='library', + content_id=obj.id + ) + + return avg_rate + + + +class MiddleBookCollectionSerializer(serializers.ModelSerializer): + """Serializer for Middle Book Collections with their books""" + books = serializers.SerializerMethodField() + + class Meta: + model = BookCollection + fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'books') + + def get_books(self, obj): + """Get all books in this collection""" + books = obj.books.filter(status=True).order_by('-view_count')[:8] + return BookSerializer(books, many=True, context=self.context).data + + +class BookDownloadSerializer(serializers.ModelSerializer): + """Serializer for book downloads""" + book_id = serializers.IntegerField(write_only=True) + + class Meta: + model = BookDownload + fields = ('id', 'book_id', 'created_at', 'updated_at', 'status') + read_only_fields = ('id', 'created_at', 'updated_at', 'status') + + def validate_book_id(self, value): + """Validate that the book exists and is active""" + try: + book = Book.objects.get(id=value, status=True) + return value + except Book.DoesNotExist: + raise serializers.ValidationError("Book not found or inactive") + + def create(self, validated_data): + """Create a new book download record""" + book_id = validated_data.pop('book_id') + user = self.context['request'].user + book = Book.objects.get(id=book_id) + + # Create or update the download record + download, created = BookDownload.objects.update_or_create( + user=user, + book=book, + defaults={'status': True} + ) + + # Increment the book's download count + book.download_count += 1 + book.save(update_fields=['download_count']) + + return download + diff --git a/apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html b/apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html new file mode 100644 index 0000000..76c0f06 --- /dev/null +++ b/apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html @@ -0,0 +1,19 @@ +{% load static %} + +
+
+ Pinned Book Collections Banner + {% comment %}
{% endcomment %} + {% comment %}
{% endcomment %} + {% comment %}

Pinned Book Collections

{% endcomment %} + {% comment %}

{% endcomment %} + {% comment %}

{% endcomment %} + {% comment %}
{% endcomment %} + {% comment %}
{% endcomment %} +
+
\ No newline at end of file diff --git a/apps/library/urls.py b/apps/library/urls.py index 5eeb026..b5099fc 100644 --- a/apps/library/urls.py +++ b/apps/library/urls.py @@ -3,13 +3,19 @@ from django.urls import path from apps.library.views import ( CategoryListView, PinnedBookCollectionListView, + MiddleBookCollectionListView, BookListView, BookDetailView, + DownloadedBooksListView, + BookDownloadCreateAPIView, ) urlpatterns = [ path('categories/', CategoryListView.as_view(), name='category-list'), path('pinned-collections/', PinnedBookCollectionListView.as_view(), name='pinned-collection-list'), + path('collections/', MiddleBookCollectionListView.as_view(), name='collection-list'), path('books/', BookListView.as_view(), name='book-list'), path('books//', BookDetailView.as_view(), name='book-detail'), + path('books/downloaded/', DownloadedBooksListView.as_view(), name='downloaded-books-list'), + path('books/download/', BookDownloadCreateAPIView.as_view(), name='book-download'), ] \ No newline at end of file diff --git a/apps/library/views.py b/apps/library/views.py index 688037a..b7c1c5e 100644 --- a/apps/library/views.py +++ b/apps/library/views.py @@ -1,16 +1,21 @@ -from django.db.models import Q, Count +from django.db.models import Count, Q from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.generics import ListAPIView, RetrieveAPIView, CreateAPIView from rest_framework.filters import SearchFilter +from rest_framework import status +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from apps.library.pagination import NoPagination from apps.library.models import * from apps.library.serializers import * from apps.library.doc import ( book_list_swagger, book_detail_swagger, category_list_swagger, - pinned_collection_list_swagger + pinned_collection_list_swagger, + middle_collection_list_swagger ) @@ -30,7 +35,7 @@ class CategoryListView(ListAPIView): return Category.objects.filter( status=True ).annotate( - books_count=Count('related_categories') + books_count_annotation=Count('related_categories') ).order_by('title') @@ -40,7 +45,7 @@ class PinnedBookCollectionListView(ListAPIView): """ serializer_class = PinnedBookCollectionSerializer permission_classes = (IsAuthenticated,) - pagination_class = None + pagination_class = NoPagination @pinned_collection_list_swagger def get(self, request, *args, **kwargs): @@ -53,6 +58,30 @@ class PinnedBookCollectionListView(ListAPIView): ).order_by('-order', '-id') + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + categories_count = Category.objects.filter(status=True).count() + from apps.bookmark.models import Bookmark + bookmarks_count = Bookmark.objects.filter( + service=Bookmark.ServiceChoices.LIBRARY, + ).count() + downloads_count = BookDownload.objects.all().count() + info = { + "categories_count": categories_count, + "bookmarks_count": bookmarks_count, + "downloads_count": downloads_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 BookListView(ListAPIView): """ API view to list books with filtering and search capabilities @@ -60,7 +89,7 @@ class BookListView(ListAPIView): serializer_class = BookSerializer permission_classes = (IsAuthenticated,) filter_backends = [SearchFilter] - search_fields = ['title', 'summary', 'author'] + search_fields = ['title', 'summary', 'publisher', 'isbn'] @book_list_swagger def get(self, request, *args, **kwargs): @@ -74,6 +103,13 @@ class BookListView(ListAPIView): if collection_id: queryset = queryset.filter(collections__id=collection_id) + # Filter by category if provided + category = self.request.query_params.get('category') + 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 middle collection if requested # if self.request.query_params.get('middle'): # middle_collections = BookCollection.objects.filter( @@ -92,7 +128,40 @@ class BookListView(ListAPIView): # if bottom_collections.exists(): # queryset = queryset.filter(collections__in=bottom_collections) - return queryset.order_by('-pin', '-created_at') + # Filter by bookmarked books if requested + 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 book IDs for the current user + bookmarked_ids = Bookmark.objects.filter( + user=self.request.user, + service=Bookmark.ServiceChoices.LIBRARY, + status=True + ).values_list('content_id', flat=True) + + # Filter books by these IDs + queryset = queryset.filter(id__in=bookmarked_ids) + + # Sort by parameter + sort = self.request.query_params.get('sort', '-pin,-created_at') + # Allowed sort fields + allowed_sorts = [ + 'created_at', '-created_at', 'view_count', '-view_count', + 'download_count', '-download_count', 'title', '-title', + 'pin', '-pin', '-pin,-created_at' + ] + if sort in allowed_sorts: + # Handle multiple sort fields (e.g., '-pin,-created_at') + if ',' in sort: + queryset = queryset.order_by(*sort.split(',')) + else: + queryset = queryset.order_by(sort) + else: + queryset = queryset.order_by('-pin', '-created_at') + + return queryset class BookDetailView(RetrieveAPIView): @@ -114,3 +183,127 @@ class BookDetailView(RetrieveAPIView): serializer = self.get_serializer(instance) return Response(serializer.data) + +class MiddleBookCollectionListView(ListAPIView): + """ + API view to list middle section book collections with their books + """ + serializer_class = MiddleBookCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = NoPagination + + @middle_collection_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return BookCollection.objects.filter( + status=True, + display_position=BookCollection.DisplayPosition.MIDDLE + ).order_by('order') + + +class DownloadedBooksListView(ListAPIView): + """ + API view to list books that have been downloaded by the current user + """ + serializer_class = BookSerializer + permission_classes = (IsAuthenticated,) + filter_backends = [SearchFilter] + search_fields = ['title', 'summary', 'publisher', 'isbn'] + + @swagger_auto_schema( + operation_id="list_downloaded_books", + operation_description=""" + Retrieve a list of books that have been downloaded by the current user. + + This endpoint returns a paginated list of books that the authenticated user has downloaded. + The results are not cached to ensure real-time accuracy of the download list. + + You can search for downloaded books by title, summary, publisher, or ISBN using the 'search' query parameter. + """, + operation_summary="List Downloaded Books", + tags=["Dobodbi - Library"], + manual_parameters=[ + openapi.Parameter( + 'search', + openapi.IN_QUERY, + description="Search downloaded books by title, summary, publisher, or ISBN", + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + 200: "List of downloaded books with pagination", + 401: "Authentication credentials were not provided or are invalid", + 500: "Internal server error occurred" + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + # Get all downloaded book IDs for the current user + downloaded_ids = BookDownload.objects.filter( + user=self.request.user, + status=True + ).values_list('book_id', flat=True) + + # Return books that match these IDs + return Book.objects.filter( + id__in=downloaded_ids, + status=True + ).order_by('-created_at') + + +class BookDownloadCreateAPIView(CreateAPIView): + """ + API view to create a book download record and increment the book's download count + """ + serializer_class = BookDownloadSerializer + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_id="download_book", + operation_description=""" + Create a book download record and increment the book's download count. + + This endpoint creates a record of a book download by the current user and increments + the book's download count. It requires the book ID in the request body. + + If the user has already downloaded the book, the existing record will be updated + with the current timestamp. + """, + operation_summary="Download Book", + tags=["Dobodbi - Library"], + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'book_id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the book to download" + ) + }, + required=['book_id'] + ), + responses={ + 201: openapi.Response( + description="Book download record created successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'created_at': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATETIME), + 'updated_at': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATETIME), + 'status': openapi.Schema(type=openapi.TYPE_BOOLEAN) + } + ) + ), + 400: "Invalid request data or book not found", + 401: "Authentication credentials were not provided or are invalid", + 500: "Internal server error occurred" + } + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + diff --git a/apps/podcast/README_COLLECTIONS.md b/apps/podcast/README_COLLECTIONS.md new file mode 100644 index 0000000..5117ef2 --- /dev/null +++ b/apps/podcast/README_COLLECTIONS.md @@ -0,0 +1,120 @@ +# مستندات مدیریت کالکشن‌های پادکست + +## نحوه مدیریت کالکشن‌ها در پنل ادمین + +### دو نوع کالکشن داریم: + +#### 1️⃣ **Pinned Collections (کالکشن‌های پین‌شده - بخش بالا)** +- **مسیر در پنل ادمین:** `Pinned Collections (Top Section)` +- **API Endpoint:** `/api/podcast/pinned-collections/` +- **کاربرد:** نمایش در بالای صفحه (carousel/featured section) +- **ویژگی‌ها:** + - نیاز به تصویر thumbnail دارد + - می‌تواند summary داشته باشد + - ترتیب نمایش با فیلد `order` مشخص می‌شود + +#### 2️⃣ **Regular Collections (کالکشن‌های معمولی - بخش میانی)** +- **مسیر در پنل ادمین:** `Regular Collections (Middle Section)` +- **API Endpoint:** `/api/podcast/collections/` +- **کاربرد:** نمایش در بخش‌های میانی صفحه +- **ویژگی‌ها:** + - تصویر thumbnail اختیاری است + - ترتیب نمایش با فیلد `order` مشخص می‌شود + +--- + +## راهنمای استفاده + +### ایجاد کالکشن جدید + +**برای کالکشن پین‌شده (بالای صفحه):** +1. به بخش `Pinned Collections (Top Section)` بروید +2. روی "Add" کلیک کنید +3. فیلدهای زیر را پر کنید: + - Title (عنوان) + - Summary (خلاصه - اختیاری) + - Thumbnail (تصویر - **الزامی**) + - Order (ترتیب نمایش) + - Status (فعال/غیرفعال) +4. پادکست‌های مورد نظر را اضافه کنید + +**برای کالکشن معمولی (بخش میانی):** +1. به بخش `Regular Collections (Middle Section)` بروید +2. روی "Add" کلیک کنید +3. فیلدهای زیر را پر کنید: + - Title (عنوان) + - Order (ترتیب نمایش) + - Status (فعال/غیرفعال) +4. پادکست‌های مورد نظر را اضافه کنید + +--- + +## نکات مهم + +### تشخیص نوع کالکشن +در لیست کالکشن‌ها، ستون **Display Position** نوع هر کالکشن را نشان می‌دهد: +- 📌 **Pinned (Top)** → کالکشن پین‌شده +- 📋 **Regular (Middle)** → کالکشن معمولی + +### تفاوت‌های کلیدی + +| ویژگی | Pinned | Regular | +|-------|--------|---------| +| تصویر thumbnail | ✅ الزامی | ⚪ اختیاری | +| فیلد summary | ✅ دارد | ❌ ندارد | +| محل نمایش | بالای صفحه | بخش میانی | +| API Endpoint | `/pinned-collections/` | `/collections/` | + +--- + +## ساختار فنی + +### مدل‌ها +```python +# مدل پایه +PodcastCollection +├── display_position: 'pinned' یا 'middle' +├── title +├── slug +├── summary (nullable) +├── thumbnail (nullable) +├── order +└── status + +# مدل‌های Proxy +PinnedPodcastCollection (display_position='pinned') +MiddlePodcastCollection (display_position='middle') +``` + +### فیلد display_position +این فیلد به صورت خودکار توسط Django Admin تنظیم می‌شود: +- در `Pinned Collections` → `display_position='pinned'` +- در `Regular Collections` → `display_position='middle'` + +--- + +## سوالات متداول + +**Q: چرا دو بخش جدا داریم؟** +A: برای جلوگیری از اشتباه و مدیریت بهتر. هر کدام کاربرد و ویژگی‌های متفاوتی دارند. + +**Q: می‌توانم یک کالکشن را از Pinned به Regular تبدیل کنم؟** +A: خیر، باید کالکشن جدیدی در بخش مورد نظر ایجاد کنید و پادکست‌ها را کپی کنید. + +**Q: چرا در API دو endpoint جدا داریم؟** +A: چون frontend نیاز دارد که کالکشن‌های بالا و میانی را جداگانه دریافت کند. + +--- + +## تغییرات اخیر + +### نسخه جدید (بهبود UX) +- ✅ نام‌های واضح‌تر برای مدل‌ها +- ✅ نمایش `Display Position` در لیست +- ✅ آیکون‌های بصری برای تشخیص سریع‌تر +- ✅ مستندات کامل + +### نسخه قبلی +- نام‌های مبهم (`Middle Section` به جای `Regular`) +- عدم نمایش نوع کالکشن در لیست +- سردرگمی در یافتن بخش مناسب \ No newline at end of file diff --git a/apps/podcast/admin.py b/apps/podcast/admin.py index fb046ae..669e2dc 100644 --- a/apps/podcast/admin.py +++ b/apps/podcast/admin.py @@ -1,23 +1,346 @@ from django.contrib import admin -from ajaxdatatable.admin import AjaxDatatable +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 unfold.admin import ModelAdmin, StackedInline, TabularInline +from django.contrib.admin import SimpleListFilter +from unfold.widgets import UnfoldAdminSelectWidget + +from unfold.decorators import display, action +from django import forms + +from utils.admin import dovoodi_admin_site +from unfold.sections import TableSection from apps.podcast.models import * +class PodcastPlaylistInCollectionInlineForCollection(TabularInline): + model = PodcastPlaylistInCollection + extra = 1 + autocomplete_fields = ('playlist',) + fields = ('playlist', 'order') + ordering = ('order',) + verbose_name = _('Playlist') + verbose_name_plural = _('Playlists') + tab = True -class PodcastInCollectionInline(admin.TabularInline): - model = PodcastInCollection - extra = 1 +class PodcastCollectionAdminBase(ModelAdmin): + list_display = ('get_title', 'get_display_position', 'status', 'order', 'count_playlists') + list_filter = ('status', 'order') + search_fields = ('title',) + ordering = ('order',) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + inlines = [PodcastPlaylistInCollectionInlineForCollection] + + fieldsets = ( + (None, { + 'fields': ('title', 'summary', 'thumbnail', 'status', 'pin_top', 'order') + }), + ) -@admin.register(PodcastCollection) -class PodcastCollectionAdmin(AjaxDatatable): - list_display = ('title',) - inlines = [PodcastInCollectionInline] + exclude = ('display_position',) + @display(description=_('Title')) + def get_title(self, obj): + return str(obj.title) -@admin.register(Podcast) -class PodcastAdmin(AjaxDatatable): - list_display = ('title', 'view_count', 'download_count', 'status') - search_fields = ('title',) + @display(description=_('Display Position')) + def get_display_position(self, obj): + if obj.display_position == PodcastCollection.DisplayPosition.PINNED: + return format_html('📌 Pinned (Top)') + else: + return format_html('📋 Regular (Middle)') + + @display(description=_('Number of Playlists')) + def count_playlists(self, obj): + count = obj.related_playlists.count() + if count > 0: + url = reverse('admin:podcast_podcastplaylist_changelist') + f'?collections__id__exact={obj.id}' + return format_html('{}', url, count) + return count + + +class PinnedPodcastCollectionForm(forms.ModelForm): + class Meta: + model = PinnedPodcastCollection + exclude = ('slug',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['thumbnail'].required = True + +class PinnedPodcastCollectionAdmin(PodcastCollectionAdminBase): + form = PinnedPodcastCollectionForm + + # Add help text to clarify this is for top section + class Media: + css = { + 'all': () + } + + def get_queryset(self, request): + return super().get_queryset(request).filter(display_position=PodcastCollection.DisplayPosition.PINNED) + + def save_model(self, request, obj, form, change): + obj.display_position = PodcastCollection.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 + +class MiddlePodcastCollectionAdmin(PodcastCollectionAdminBase): + + fieldsets = ( + (None, { + 'fields': ('title', 'status', 'pin_top', 'order') + }), + ) + + # Add help text to clarify this is for middle section + class Media: + css = { + 'all': () + } + + def get_queryset(self, request): + return super().get_queryset(request).filter(display_position=PodcastCollection.DisplayPosition.MIDDLE) + + def save_model(self, request, obj, form, change): + obj.display_position = PodcastCollection.DisplayPosition.MIDDLE + super().save_model(request, obj, form, change) + + +class PodcastCategoryAdmin(ModelAdmin): + list_display = ('title', 'slug', 'status', 'order', 'count_playlists', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title', 'slug') + search_help_text = _("Search by title or slug") + search_fields_placeholder = _("Search categories") + + @admin.display(description=_('Number of Playlists')) + def count_playlists(self, obj): + count = obj.playlists.filter(status=True).count() + if count > 0: + url = reverse('admin:podcast_podcastplaylist_changelist') + f'?categories__id__exact={obj.id}' + return format_html('{}', 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 PodcastAdmin(ModelAdmin): + list_display = ('title', 'slug', 'status', 'view_count', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title', 'slug', 'description') + autocomplete_fields = ('categories',) + save_as = True + search_help_text = _("Search by title, slug, or description") + search_fields_placeholder = _("Search podcasts") + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'description', 'thumbnail', 'categories') + }), + (_('Audio Information'), { + 'fields': ('audio_file', 'audio_time') + }), + (_('Status'), { + 'fields': ('status',) + }), + (_('Statistics'), { + 'fields': ('view_count', 'download_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 + + +class PodcastPlaylistItemForm(forms.ModelForm): + class Meta: + model = PlaylistItem + fields = ('podcast', 'priority') + + def clean_podcast(self): + podcast = self.cleaned_data.get('podcast') + if not podcast: + return podcast + + # If we're editing, exclude the current instance from the check + instance = getattr(self, 'instance', None) + if instance and instance.pk and instance.podcast == podcast: + return podcast + + # Check if this podcast exists in another playlist + existing_item = PlaylistItem.objects.filter(podcast=podcast).first() + if existing_item: + playlist_name = existing_item.playlist.title + raise forms.ValidationError( + _('This podcast is already used in playlist "{}". Each podcast can only be in one playlist.').format(playlist_name) + ) + return podcast + + +class PodcastPlaylistItemInline(StackedInline): + model = PlaylistItem + form = PodcastPlaylistItemForm + extra = 1 + autocomplete_fields = ('podcast',) + fields = ('podcast', 'priority') + ordering = ('priority',) + verbose_name = _('Playlist Item') + verbose_name_plural = _('Playlist Items') + + +class PodcastPlaylistInCollectionInline(TabularInline): + model = PodcastPlaylistInCollection + extra = 1 + raw_id_fields = ('collection',) + fields = ('collection', 'order') + ordering = ('order',) + verbose_name = _('Collection') + verbose_name_plural = _('Collections') + tab = True + + +class PodcastPlaylistAdmin(ModelAdmin): + list_display = ('title', 'slug', 'status', 'order', 'view_count', 'count_podcasts', 'created_at') + list_filter = ('status', 'created_at', 'categories') + search_fields = ('title', 'slug', 'slogan', 'description') + autocomplete_fields = ('categories',) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + inlines = [PodcastPlaylistItemInline, PodcastPlaylistInCollectionInline] + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'slogan', 'description', 'thumbnail') + }), + (_('Categories'), { + 'fields': ('categories',) + }), + (_('Display Settings'), { + 'fields': ('order', 'status') + }), + (_('Statistics'), { + 'fields': ('view_count', 'total_time') + }), + ) + + 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 = False + if form.base_fields.get('total_time'): + form.base_fields['total_time'].required = False + form.base_fields['total_time'].help_text = _('Will be auto-calculated from podcasts') + return form + + + @display(description=_('Number of Podcasts')) + def count_podcasts(self, obj): + count = obj.playlist_items.count() + if count > 0: + return format_html('{}', count) + return count + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + # Auto-calculate total_time + obj.total_time = obj.calculate_total_time() + obj.save(update_fields=['total_time']) + + def save_formset(self, request, form, formset, change): + """ + Additional validation to ensure each podcast is used in only one playlist + """ + instances = formset.save(commit=False) + + # Collect all podcasts that are being saved + podcasts_to_save = [] + for instance in instances: + if instance.podcast: + podcasts_to_save.append(instance.podcast) + + # Check for duplicate podcasts in this formset + podcast_counts = {} + for podcast in podcasts_to_save: + podcast_counts[podcast.id] = podcast_counts.get(podcast.id, 0) + 1 + + duplicate_podcasts = [podcast_id for podcast_id, count in podcast_counts.items() if count > 1] + if duplicate_podcasts: + # If there are duplicate podcasts in this form, show an error + formset._non_form_errors = formset.error_class( + [_('A podcast cannot be used multiple times in the same playlist.')] + ) + return + + # Check if podcasts are used in other playlists + for instance in instances: + if instance.podcast: # For both new and edited items + playlist_id = form.instance.pk + query = PlaylistItem.objects.filter( + podcast=instance.podcast + ).exclude( + playlist_id=playlist_id + ) + + # If we're editing an existing item, exclude it from the check + if instance.pk: + query = query.exclude(pk=instance.pk) + + existing_item = query.first() + + if existing_item: + playlist_name = existing_item.playlist.title + formset._non_form_errors = formset.error_class( + [_('Podcast "{}" is already used in playlist "{}". Each podcast can only be in one playlist.').format( + instance.podcast.title, playlist_name + )] + ) + return + + # If all validations pass, save the formset + super().save_formset(request, form, formset, change) + + +class UserPlaylistAdmin(ModelAdmin): + list_display = ('user', 'podcast', 'status', 'created_at', 'updated_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('user__username', 'podcast__title') + autocomplete_fields = ('user', 'podcast') + + fieldsets = ( + (None, { + 'fields': ('user', 'podcast', 'status') + }), + ) + + +dovoodi_admin_site.register(PodcastCategory, PodcastCategoryAdmin) +dovoodi_admin_site.register(Podcast, PodcastAdmin) +dovoodi_admin_site.register(PinnedPodcastCollection, PinnedPodcastCollectionAdmin) +dovoodi_admin_site.register(MiddlePodcastCollection, MiddlePodcastCollectionAdmin) +dovoodi_admin_site.register(PodcastPlaylist, PodcastPlaylistAdmin) +dovoodi_admin_site.register(UserPlaylist, UserPlaylistAdmin) diff --git a/apps/podcast/management/commands/cleanup_podcast_data.py b/apps/podcast/management/commands/cleanup_podcast_data.py new file mode 100644 index 0000000..3bbca2e --- /dev/null +++ b/apps/podcast/management/commands/cleanup_podcast_data.py @@ -0,0 +1,61 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.podcast.models import PodcastCategory, PodcastCollection, PodcastPlaylist, PlaylistItem + + +class Command(BaseCommand): + help = 'Delete all data from PodcastCategory, PodcastCollection, and PodcastPlaylist (keeps Podcast model data)' + + def add_arguments(self, parser): + parser.add_argument( + '--confirm', + action='store_true', + help='Confirm deletion without prompting' + ) + + def handle(self, *args, **options): + confirm = options.get('confirm', False) + + # Count current data + category_count = PodcastCategory.objects.count() + collection_count = PodcastCollection.objects.count() + playlist_count = PodcastPlaylist.objects.count() + playlist_item_count = PlaylistItem.objects.count() + + self.stdout.write(self.style.WARNING('\n=== Current Data Count ===')) + self.stdout.write(f'PodcastCategory: {category_count}') + self.stdout.write(f'PodcastCollection: {collection_count}') + self.stdout.write(f'PodcastPlaylist: {playlist_count}') + self.stdout.write(f'PlaylistItem: {playlist_item_count}') + self.stdout.write(self.style.WARNING('\n=== Podcast Data Will NOT Be Deleted ===\n')) + + if not confirm: + user_input = input('Are you sure you want to delete this data? Type "yes" to confirm: ') + if user_input.lower() != 'yes': + self.stdout.write(self.style.ERROR('Operation cancelled.')) + return + + try: + with transaction.atomic(): + # Delete in order to respect foreign key constraints + # 1. Delete PlaylistItem first (references PodcastPlaylist) + deleted_playlist_items = PlaylistItem.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_playlist_items[0]} PlaylistItems')) + + # 2. Delete PodcastPlaylist (may reference PodcastCategory and PodcastCollection through M2M) + deleted_playlists = PodcastPlaylist.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_playlists[0]} PodcastPlaylists')) + + # 3. Delete PodcastCollection + deleted_collections = PodcastCollection.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_collections[0]} PodcastCollections')) + + # 4. Delete PodcastCategory + deleted_categories = PodcastCategory.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_categories[0]} PodcastCategories')) + + self.stdout.write(self.style.SUCCESS('\n✓ All data deleted successfully!')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\n✗ Error during deletion: {str(e)}')) + raise diff --git a/apps/podcast/management/commands/convert_videos_to_podcasts.py b/apps/podcast/management/commands/convert_videos_to_podcasts.py new file mode 100644 index 0000000..2c5fdf8 --- /dev/null +++ b/apps/podcast/management/commands/convert_videos_to_podcasts.py @@ -0,0 +1,227 @@ +import os +import subprocess +import tempfile +from datetime import time +from pathlib import Path + +from django.core.management.base import BaseCommand +from django.core.files import File +from django.db import transaction + +from apps.video.models import Video +from apps.podcast.models import Podcast + + +class Command(BaseCommand): + help = 'Convert all videos to podcasts by extracting audio and copying metadata' + + def add_arguments(self, parser): + parser.add_argument( + '--skip-existing', + action='store_true', + help='Skip podcasts that already exist with the same slug' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be done without actually converting' + ) + parser.add_argument( + '--limit', + type=int, + default=None, + help='Limit number of videos to process (for testing)' + ) + + def handle(self, *args, **options): + skip_existing = options['skip_existing'] + self.dry_run = options.get('dry_run', False) + limit = options.get('limit') + + if self.dry_run: + self.stdout.write(self.style.WARNING('DRY RUN MODE - No actual conversions will be performed')) + + # Get all videos + videos = Video.objects.filter(status=True).order_by('id') + + if limit: + videos = videos[:limit] + + total_videos = videos.count() + + if total_videos == 0: + self.stdout.write(self.style.ERROR('No videos found in database.')) + return + + self.stdout.write(f'\nFound {total_videos} videos to convert') + self.stdout.write(self.style.WARNING('This process will take time as it extracts audio from each video...\n')) + + processed_count = 0 + skipped_count = 0 + failed_count = 0 + + for video in videos: + try: + # Check if podcast already exists with same slug + if skip_existing and Podcast.objects.filter(slug=video.slug).exists(): + self.stdout.write(self.style.WARNING(f'Skipping {video.slug}: Already exists')) + skipped_count += 1 + continue + + if self.dry_run: + self.stdout.write(f'[DRY RUN] Would convert: {video.title}') + processed_count += 1 + continue + + # Process the video + success = self.convert_video_to_podcast(video) + if success: + processed_count += 1 + else: + failed_count += 1 + + except Exception as e: + self.stdout.write(self.style.ERROR(f'✗ Error processing {video.slug}: {str(e)}')) + failed_count += 1 + + self.stdout.write(self.style.SUCCESS(f'\n✓ Conversion complete!')) + self.stdout.write(f' Processed: {processed_count}') + self.stdout.write(f' Skipped: {skipped_count}') + self.stdout.write(f' Failed: {failed_count}') + + def convert_video_to_podcast(self, video): + """Convert a single video to podcast by extracting audio""" + self.stdout.write(f'\nProcessing: {video.title}') + + # Check if video has a file + if not video.video_file: + self.stdout.write(self.style.WARNING(f' ⚠ No video file found, skipping')) + return False + + temp_dir = None + try: + # Create temporary directory + temp_dir = tempfile.mkdtemp() + + # Extract audio from video + audio_path = self.extract_audio(video, temp_dir) + if not audio_path: + return False + + # Ensure unique slug + slug = video.slug + counter = 1 + while Podcast.objects.filter(slug=slug).exists(): + slug = f"{video.slug}-{counter}" + counter += 1 + + if slug != video.slug: + self.stdout.write(self.style.WARNING(f' ⚠ Slug conflict, using: {slug}')) + + # Create Podcast object + with transaction.atomic(): + podcast = Podcast( + title=video.title, + slug=slug, + description=video.description, + audio_time=video.video_time, # Same duration + status=True, + view_count=0, # Start fresh + download_count=0 + ) + + # Copy thumbnail if exists + if video.thumbnail: + try: + # Read the video's thumbnail + video.thumbnail.open('rb') + thumbnail_content = video.thumbnail.read() + video.thumbnail.close() + + # Create a temporary file for the thumbnail + from django.core.files.base import ContentFile + podcast.thumbnail.save( + f'{slug}_thumb.jpg', + ContentFile(thumbnail_content), + save=False + ) + self.stdout.write(f' ✓ Thumbnail copied') + except Exception as e: + self.stdout.write(self.style.WARNING(f' ⚠ Could not copy thumbnail: {str(e)}')) + + # Save audio file + with open(audio_path, 'rb') as audio_file: + podcast.audio_file.save( + f'{slug}.mp3', + File(audio_file), + save=False + ) + + podcast.save() + self.stdout.write(self.style.SUCCESS(f'✓ Saved podcast: {podcast.title} (slug: {slug})')) + return True + + except Exception as e: + self.stdout.write(self.style.ERROR(f'✗ Error: {str(e)}')) + return False + + finally: + # Cleanup temporary files + if temp_dir and os.path.exists(temp_dir): + import shutil + shutil.rmtree(temp_dir, ignore_errors=True) + + def extract_audio(self, video, temp_dir): + """Extract audio from video file using ffmpeg""" + try: + self.stdout.write(f' Extracting audio...') + + # Get the video file path + video_path = video.video_file.path + + if not os.path.exists(video_path): + self.stdout.write(self.style.ERROR(f' ✗ Video file not found at: {video_path}')) + return None + + # Output audio path + audio_path = os.path.join(temp_dir, f'{video.slug}.mp3') + + # Extract audio using ffmpeg + # -vn: no video + # -acodec libmp3lame: use MP3 codec + # -q:a 2: quality (0-9, where 0 is best) + cmd = [ + 'ffmpeg', + '-i', video_path, + '-vn', # No video + '-acodec', 'libmp3lame', + '-q:a', '2', # High quality + audio_path, + '-y' # Overwrite output file + ] + + self.stdout.write(f' Running ffmpeg...') + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=600 # 10 minutes timeout + ) + + if result.returncode == 0 and os.path.exists(audio_path): + audio_size = os.path.getsize(audio_path) / (1024 * 1024) # Size in MB + self.stdout.write(self.style.SUCCESS(f' ✓ Audio extracted: {audio_size:.2f} MB')) + return audio_path + else: + error_msg = result.stderr.decode('utf-8', errors='ignore') + self.stdout.write(self.style.ERROR(f' ✗ Audio extraction failed')) + if error_msg: + self.stdout.write(f' Error details: {error_msg[:200]}') + return None + + except subprocess.TimeoutExpired: + self.stdout.write(self.style.ERROR(f' ✗ Timeout during audio extraction')) + return None + except Exception as e: + self.stdout.write(self.style.ERROR(f' ✗ Error: {str(e)}')) + return None diff --git a/apps/podcast/management/commands/create_podcast_categories.py b/apps/podcast/management/commands/create_podcast_categories.py new file mode 100644 index 0000000..1993560 --- /dev/null +++ b/apps/podcast/management/commands/create_podcast_categories.py @@ -0,0 +1,88 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.podcast.models import PodcastCategory + + +class Command(BaseCommand): + help = 'Create podcast categories in Russian language' + + # Russian podcast categories + CATEGORIES_DATA = [ + { + 'title': 'Пророки и посланники', + 'order': 10 + }, + { + 'title': 'Имамы Ахль аль-Байт', + 'order': 20 + }, + { + 'title': 'Коранические истории', + 'order': 30 + }, + { + 'title': 'Исламская философия', + 'order': 40 + }, + { + 'title': 'Нравственность и этика', + 'order': 50 + }, + { + 'title': 'История ислама', + 'order': 60 + }, + { + 'title': 'Кербела и Ашура', + 'order': 70 + }, + { + 'title': 'Духовное развитие', + 'order': 80 + } + ] + + def add_arguments(self, parser): + parser.add_argument( + '--clean', + action='store_true', + help='Delete existing categories before creating new ones' + ) + + def handle(self, *args, **options): + clean = options.get('clean', False) + + if clean: + deleted_count = PodcastCategory.objects.count() + PodcastCategory.objects.all().delete() + self.stdout.write(self.style.WARNING(f'Deleted {deleted_count} existing categories')) + + try: + with transaction.atomic(): + created_categories = [] + + for category_data in self.CATEGORIES_DATA: + # Check if category already exists + title = category_data['title'] + category, created = PodcastCategory.objects.get_or_create( + title=title, + defaults={ + 'order': category_data['order'], + 'status': True + } + ) + + if created: + self.stdout.write(self.style.SUCCESS(f'✓ Created category: {category.title}')) + created_categories.append(category) + else: + self.stdout.write(self.style.WARNING(f'⚠ Category already exists: {category.title}')) + + if created_categories: + self.stdout.write(self.style.SUCCESS(f'\n✓ Successfully created {len(created_categories)} categories!')) + else: + self.stdout.write(self.style.WARNING('\nNo new categories created (all already exist)')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\n✗ Error during creation: {str(e)}')) + raise diff --git a/apps/podcast/management/commands/create_podcast_playlists.py b/apps/podcast/management/commands/create_podcast_playlists.py new file mode 100644 index 0000000..ddb36d3 --- /dev/null +++ b/apps/podcast/management/commands/create_podcast_playlists.py @@ -0,0 +1,154 @@ +import random +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.podcast.models import Podcast, PodcastPlaylist, PlaylistItem, PodcastCategory + + +class Command(BaseCommand): + help = 'Create 10 podcast playlists in Russian language about prophets and imams, and add all podcasts to each playlist' + + # Russian playlist data about prophets and imams + # Each playlist has associated category titles + PLAYLISTS_DATA = [ + { + 'title': 'Лекции о Пророке Мухаммаде (да благословит его Аллах)', + 'slogan': 'Аудио лекции о жизни последнего пророка', + 'description': 'Полная коллекция аудио лекций о жизни, учениях и наследии Пророка Мухаммада (мир ему и благословение Аллаха). Узнайте о его миссии, характере и влиянии на человечество.', + 'categories': ['Пророки и посланники', 'История ислама'] + }, + { + 'title': 'Истории пророков в аудио формате', + 'slogan': 'Повествования о посланниках Аллаха', + 'description': 'Глубокое изучение историй пророков, упомянутых в Священном Коране. От Адама до Мухаммада (мир им всем), узнайте об их испытаниях, учениях и вере в аудио лекциях.', + 'categories': ['Пророки и посланники', 'Коранические истории'] + }, + { + 'title': 'Имам Али: Аудио наставления', + 'slogan': 'Мудрость первого Имама в аудио', + 'description': 'Исследование жизни, учений и мудрости Имама Али ибн Аби Талиба через аудио лекции. Его речи, письма и руководство для верующих.', + 'categories': ['Имамы Ахль аль-Байт', 'Исламская философия'] + }, + { + 'title': 'Имам Хусейн: Аудио о Кербеле', + 'slogan': 'Подкасты о жертве ради истины', + 'description': 'Полное понимание событий Ашуры и мученичества Имама Хусейна через аудио материалы. Узнайте о его стойкости против угнетения.', + 'categories': ['Имамы Ахль аль-Байт', 'Кербела и Ашура', 'История ислама'] + }, + { + 'title': 'Двенадцать Имамов: Аудио курс', + 'slogan': 'Светильники руководства в аудио', + 'description': 'Всестороннее изучение жизни и учений двенадцати непогрешимых Имамов из рода Пророка. Их роль в сохранении истинного ислама в аудио лекциях.', + 'categories': ['Имамы Ахль аль-Байт', 'История ислама'] + }, + { + 'title': 'Фатима аз-Захра: Аудио лекции', + 'slogan': 'Образец для верующих женщин в подкастах', + 'description': 'Жизнь, добродетели и положение Фатимы аз-Захры, любимой дочери Пророка Мухаммада. Ее роль как матери Имамов в аудио материалах.', + 'categories': ['Имамы Ахль аль-Байт', 'Нравственность и этика'] + }, + { + 'title': 'Имам Махди: Аудио о ожидании', + 'slogan': 'Подкасты о обещанном спасителе', + 'description': 'Понимание концепции Имама Махди, последнего Имама, который установит справедливость на земле. Признаки его появления в аудио лекциях.', + 'categories': ['Имамы Ахль аль-Байт', 'Духовное развитие'] + }, + { + 'title': 'Чудеса пророков: Аудио рассказы', + 'slogan': 'Божественные знамения в подкастах', + 'description': 'Исследование чудес, дарованных пророкам Аллахом через аудио. От посоха Мусы до раскола луны Пророком Мухаммадом.', + 'categories': ['Пророки и посланники', 'Коранические истории'] + }, + { + 'title': 'Нравственность Ахль аль-Байт: Аудио', + 'slogan': 'Духовное совершенствование через подкасты', + 'description': 'Практические учения Пророка и Имамов о нравственности, этике и духовном росте. Применение исламских принципов в повседневной жизни через аудио.', + 'categories': ['Имамы Ахль аль-Байт', 'Нравственность и этика', 'Духовное развитие'] + }, + { + 'title': 'Имам Риза: Аудио наследие', + 'slogan': 'Восьмой Имам в аудио лекциях', + 'description': 'Жизнь, дискуссии и мученичество Имама Ризы, восьмого Имама. Его диалоги с учеными различных религий и его роль в распространении знаний.', + 'categories': ['Имамы Ахль аль-Байт', 'Исламская философия', 'История ислама'] + } + ] + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be created without actually creating' + ) + + def handle(self, *args, **options): + dry_run = options.get('dry_run', False) + + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN MODE - No actual creation will be performed')) + + # Get all podcasts + podcasts = list(Podcast.objects.filter(status=True).order_by('id')) + podcast_count = len(podcasts) + + if podcast_count == 0: + self.stdout.write(self.style.ERROR('No podcasts found in database. Please add podcasts first.')) + return + + self.stdout.write(f'\nFound {podcast_count} podcasts in database') + self.stdout.write(f'Creating {len(self.PLAYLISTS_DATA)} playlists...\n') + + if dry_run: + for idx, playlist_data in enumerate(self.PLAYLISTS_DATA, start=1): + self.stdout.write(f'{idx}. {playlist_data["title"]}') + self.stdout.write(f' Slogan: {playlist_data["slogan"]}') + self.stdout.write(f' Would add {podcast_count} podcasts to this playlist\n') + return + + try: + with transaction.atomic(): + created_playlists = [] + + for idx, playlist_data in enumerate(self.PLAYLISTS_DATA, start=1): + # Create playlist + playlist = PodcastPlaylist.objects.create( + title=playlist_data['title'], + slogan=playlist_data['slogan'], + description=playlist_data['description'], + order=idx * 10, + status=True + ) + + # Add categories to playlist + category_titles = playlist_data.get('categories', []) + if category_titles: + categories = PodcastCategory.objects.filter(title__in=category_titles) + playlist.categories.set(categories) + self.stdout.write(f' Added {categories.count()} categories') + + self.stdout.write(self.style.SUCCESS(f'✓ Created playlist: {playlist.title}')) + + # Add all podcasts to this playlist + playlist_items_created = 0 + for priority, podcast in enumerate(podcasts, start=1): + PlaylistItem.objects.create( + playlist=playlist, + podcast=podcast, + priority=priority + ) + playlist_items_created += 1 + + # Calculate and save total time + total_time = playlist.calculate_total_time() + playlist.total_time = total_time + playlist.save(update_fields=['total_time']) + + self.stdout.write(f' Added {playlist_items_created} podcasts to playlist') + self.stdout.write(f' Total duration: {total_time}\n') + + created_playlists.append(playlist) + + self.stdout.write(self.style.SUCCESS(f'\n✓ Successfully created {len(created_playlists)} playlists!')) + self.stdout.write(self.style.SUCCESS(f'✓ Each playlist contains all {podcast_count} podcasts')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\n✗ Error during creation: {str(e)}')) + raise diff --git a/apps/podcast/migrations/0001_initial.py b/apps/podcast/migrations/0001_initial.py new file mode 100755 index 0000000..ba1b911 --- /dev/null +++ b/apps/podcast/migrations/0001_initial.py @@ -0,0 +1,170 @@ +# Generated by Django 5.1.8 on 2025-05-06 11:46 + +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='PodcastCategory', + 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': 'Podcast Category', + 'verbose_name_plural': 'Podcast Categories', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='PodcastCollection', + 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='podcast/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': 'Podcast Collection', + 'verbose_name_plural': 'Podcasts Collections', + }, + ), + migrations.CreateModel( + name='PodcastPlaylist', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('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': 'Podcast Playlist', + 'verbose_name_plural': 'Podcast Playlists', + }, + ), + migrations.CreateModel( + name='Podcast', + 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='book_thumbnails/')), + ('description', models.TextField(null=True)), + ('audio_file', models.FileField(blank=True, null=True, upload_to='podcast/audio/')), + ('audio_time', models.TimeField()), + ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('download_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(related_name='podcasts', to='podcast.podcastcategory', verbose_name='categories')), + ], + options={ + 'verbose_name': 'Podcast', + 'verbose_name_plural': 'Podcasts', + }, + ), + migrations.CreateModel( + name='MiddlePodcastCollection', + fields=[ + ], + options={ + 'verbose_name': 'Middle Section Podcast Collection', + 'verbose_name_plural': 'Middle Section Podcast Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('podcast.podcastcollection',), + ), + migrations.CreateModel( + name='PinnedPodcastCollection', + fields=[ + ], + options={ + 'verbose_name': 'Pinned Podcast Collection', + 'verbose_name_plural': 'Pinned Podcast Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('podcast.podcastcollection',), + ), + migrations.CreateModel( + name='PodcastInCollection', + 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')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_podcasts', to='podcast.podcastcollection', verbose_name='collection')), + ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_items', to='podcast.podcast', verbose_name='podcast')), + ], + options={ + 'verbose_name': 'Podcast in Collection', + 'verbose_name_plural': 'Podcasts in Collections', + 'ordering': ['order'], + 'unique_together': {('collection', 'podcast')}, + }, + ), + migrations.AddField( + model_name='podcastcollection', + name='podcasts', + field=models.ManyToManyField(related_name='collections', through='podcast.PodcastInCollection', to='podcast.podcast', verbose_name='podcasts'), + ), + migrations.CreateModel( + name='PlaylistItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_appearances', to='podcast.podcast', verbose_name='podcast')), + ('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_items', to='podcast.podcastplaylist', verbose_name='playlist')), + ], + options={ + 'verbose_name': 'Playlist Item', + 'verbose_name_plural': 'Playlist Items', + 'ordering': ['priority'], + 'unique_together': {('playlist', 'podcast')}, + }, + ), + migrations.CreateModel( + name='UserPlaylist', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='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')), + ('podcast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_playlists', to='podcast.podcast', verbose_name='podcast')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_playlists', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'User Playlist', + 'verbose_name_plural': 'User Playlists', + 'unique_together': {('user', 'podcast')}, + }, + ), + ] diff --git a/apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py b/apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py new file mode 100755 index 0000000..cc6b6ac --- /dev/null +++ b/apps/podcast/migrations/0002_podcast_collections_alter_podcast_categories_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.8 on 2025-05-06 12:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcast', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='podcast', + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_podcasts', through='podcast.PodcastInCollection', to='podcast.podcastcollection', verbose_name='collections'), + ), + migrations.AlterField( + model_name='podcast', + name='categories', + field=models.ManyToManyField(blank=True, related_name='podcasts', to='podcast.podcastcategory', verbose_name='categories'), + ), + migrations.AlterField( + model_name='podcast', + name='download_count', + field=models.PositiveBigIntegerField(default=0, verbose_name='download_count view count'), + ), + migrations.AlterField( + model_name='podcastcollection', + name='podcasts', + field=models.ManyToManyField(related_name='related_collections_podcast', through='podcast.PodcastInCollection', to='podcast.podcast', verbose_name='podcasts'), + ), + migrations.AlterField( + model_name='podcastincollection', + name='podcast', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_collections', to='podcast.podcast', verbose_name='podcast'), + ), + ] diff --git a/apps/podcast/migrations/0003_refactor_podcast_models.py b/apps/podcast/migrations/0003_refactor_podcast_models.py new file mode 100644 index 0000000..b7fc16e --- /dev/null +++ b/apps/podcast/migrations/0003_refactor_podcast_models.py @@ -0,0 +1,115 @@ +# Generated by Django 3.2.4 on 2025-12-01 13:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcast', '0002_podcast_collections_alter_podcast_categories_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PodcastPlaylistInCollection', + 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')), + ], + options={ + 'verbose_name': 'Podcast Playlist in Collection', + 'verbose_name_plural': 'Podcast Playlists in Collections', + 'ordering': ['order'], + }, + ), + migrations.AlterModelOptions( + name='middlepodcastcollection', + options={'verbose_name': 'Regular Collection (Middle Section)', 'verbose_name_plural': 'Regular Collections (Middle Section)'}, + ), + migrations.AlterModelOptions( + name='pinnedpodcastcollection', + options={'verbose_name': 'Pinned Collection (Top Section)', 'verbose_name_plural': 'Pinned Collections (Top Section)'}, + ), + migrations.AlterModelOptions( + name='podcastplaylist', + options={'ordering': ['order', '-created_at'], 'verbose_name': 'Podcast Playlist', 'verbose_name_plural': 'Podcast Playlists'}, + ), + migrations.RemoveField( + model_name='podcast', + name='collections', + ), + migrations.RemoveField( + model_name='podcastcollection', + name='podcasts', + ), + migrations.AddField( + model_name='podcastplaylist', + name='categories', + field=models.ManyToManyField(blank=True, related_name='playlists', to='podcast.PodcastCategory', verbose_name='categories'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='description'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='order'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='slogan', + field=models.CharField(blank=True, max_length=512, null=True, verbose_name='slogan'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='slug', + field=models.SlugField(allow_unicode=True, blank=True, null=True, unique=True, verbose_name='slug'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='status', + field=models.BooleanField(default=True, verbose_name='status'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='podcast/playlist/thumbnails/', verbose_name='thumbnail'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='total_time', + field=models.DurationField(blank=True, null=True, verbose_name='total time'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='view_count', + field=models.PositiveBigIntegerField(default=0, verbose_name='view count'), + ), + migrations.DeleteModel( + name='PodcastInCollection', + ), + migrations.AddField( + model_name='podcastplaylistincollection', + name='collection', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_playlists', to='podcast.podcastcollection', verbose_name='collection'), + ), + migrations.AddField( + model_name='podcastplaylistincollection', + name='playlist', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_collections', to='podcast.podcastplaylist', verbose_name='playlist'), + ), + migrations.AddField( + model_name='podcastplaylist', + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_playlists', through='podcast.PodcastPlaylistInCollection', to='podcast.PodcastCollection', verbose_name='collections'), + ), + migrations.AlterUniqueTogether( + name='podcastplaylistincollection', + unique_together={('collection', 'playlist')}, + ), + ] diff --git a/apps/podcast/models.py b/apps/podcast/models.py index 4e4cefd..d048a39 100644 --- a/apps/podcast/models.py +++ b/apps/podcast/models.py @@ -1,5 +1,7 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ +from utils import generate_slug_for_model class PodcastCategory(models.Model): @@ -14,66 +16,81 @@ class PodcastCategory(models.Model): def __str__(self): return self.title + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(PodcastCategory, self.title) + super().save(*args, **kwargs) + class Meta: - verbose_name = _('Video Category') - verbose_name_plural = _('Video Categories') + verbose_name = _('Podcast Category') + verbose_name_plural = _('Podcast Categories') ordering = ['order'] class PodcastCollection(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='podcast/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')) - videos = models.ManyToManyField( - Video, - through='PodcastInCollection', - related_name='collections', - verbose_name=_('podcasts'), - ) 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(PodcastCollection, self.title) + super().save(*args, **kwargs) + class Meta: verbose_name = _('Podcast Collection') verbose_name_plural = _('Podcasts Collections') -class PodcastInCollection(models.Model): - video_collection = models.ForeignKey( - VideoCollection, on_delete=models.CASCADE, related_name='podcasts_in_collection', verbose_name=_('podcast collection') - ) - podcast = models.ForeignKey( - Podcast, on_delete=models.CASCADE, related_name='collections_podcasts', verbose_name=_('podcasts') - ) - priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) +class PinnedPodcastCollection(PodcastCollection): + class Meta: + proxy = True + verbose_name = _('Pinned Collection (Top Section)') + verbose_name_plural = _('Pinned Collections (Top Section)') - def __str__(self): - return f"{self.podcast_collection.title} - {self.podcast.title} (Priority: {self.priority})" +class MiddlePodcastCollection(PodcastCollection): class Meta: - verbose_name = _('Podcast in Collection') - verbose_name_plural = _('Podcasts in Collection') - ordering = ['priority'] + proxy = True + verbose_name = _('Regular Collection (Middle Section)') + verbose_name_plural = _('Regular Collections (Middle Section)') class Podcast(models.Model): - + title = models.CharField(max_length=255, null=True) slug = models.SlugField(allow_unicode=True, unique=True) - thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_( - 'image allowed' - )) + thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed')) description = models.TextField(null=True) - categories = models.ManyToManyField(PodcastCategory, related_name='podcasts', verbose_name=_('categories')) + + categories = models.ManyToManyField(PodcastCategory, related_name='podcasts', verbose_name=_('categories'), blank=True) + audio_file = models.FileField(upload_to='podcast/audio/', null=True, blank=True) - audio_url = models.CharField(max_length=655, null=True, blank=True) audio_time = models.TimeField() view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) - download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('download_count view count')) status = models.BooleanField(default=True, verbose_name=_('status')) @@ -82,8 +99,187 @@ class Podcast(models.Model): 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(Podcast, self.title) + super().save(*args, **kwargs) + + class Meta: verbose_name = _('Podcast') verbose_name_plural = _('Podcasts') + +class PodcastPlaylist(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(allow_unicode=True, unique=True, null=True, blank=True, verbose_name=_('slug')) + slogan = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('slogan')) + description = models.TextField(null=True, blank=True, verbose_name=_('description')) + thumbnail = models.ImageField(upload_to='podcast/playlist/thumbnails/', null=True, blank=True, verbose_name=_('thumbnail')) + + categories = models.ManyToManyField( + PodcastCategory, + related_name='playlists', + verbose_name=_('categories'), + blank=True, + ) + collections = models.ManyToManyField( + PodcastCollection, + through='PodcastPlaylistInCollection', + related_name='related_playlists', + verbose_name=_('collections'), + blank=True + ) + + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + status = models.BooleanField(default=True, verbose_name=_('status')) + view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + total_time = models.DurationField(null=True, blank=True, verbose_name=_('total time')) + + 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): + """Increment the view count for this playlist""" + self.view_count += 1 + self.save(update_fields=['view_count']) + return self.view_count + + def calculate_total_time(self): + """Calculate total duration of all podcasts in this playlist""" + from datetime import timedelta + total_seconds = 0 + + for item in self.playlist_items.select_related('podcast'): + audio_time = item.podcast.audio_time + total_seconds += audio_time.hour * 3600 + audio_time.minute * 60 + audio_time.second + + return timedelta(seconds=total_seconds) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(PodcastPlaylist, self.title) + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('Podcast Playlist') + verbose_name_plural = _('Podcast Playlists') + ordering = ['order', '-created_at'] + + +class PodcastPlaylistInCollection(models.Model): + collection = models.ForeignKey( + PodcastCollection, + on_delete=models.CASCADE, + related_name='collection_playlists', + verbose_name=_('collection') + ) + playlist = models.ForeignKey( + PodcastPlaylist, + on_delete=models.CASCADE, + related_name='playlist_collections', + verbose_name=_('playlist') + ) + 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.playlist.title}" + + class Meta: + verbose_name = _('Podcast Playlist in Collection') + verbose_name_plural = _('Podcast Playlists in Collections') + ordering = ['order'] + unique_together = ['collection', 'playlist'] + + +class PlaylistItem(models.Model): + playlist = models.ForeignKey( + PodcastPlaylist, + on_delete=models.CASCADE, + related_name='playlist_items', + verbose_name=_('playlist') + ) + podcast = models.ForeignKey( + Podcast, + on_delete=models.CASCADE, + related_name='playlist_appearances', + verbose_name=_('podcast') + ) + priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) + + 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.playlist.title} - {self.podcast.title} (Priority: {self.priority})" + + class Meta: + verbose_name = _('Playlist Item') + verbose_name_plural = _('Playlist Items') + ordering = ['priority'] + unique_together = ['playlist', 'podcast'] + + +from django.contrib.auth import get_user_model + +User = get_user_model() + +class UserPlaylist(models.Model): + """ + Model to track which podcasts a user has added to their personal playlist + """ + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='podcast_playlists', + verbose_name=_('user') + ) + podcast = models.ForeignKey( + Podcast, + on_delete=models.CASCADE, + related_name='user_playlists', + verbose_name=_('podcast') + ) + 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 = _('User Playlist') + verbose_name_plural = _('User Playlists') + unique_together = ['user', 'podcast'] + + def __str__(self): + return f"{self.user.username} - {self.podcast.title}" + + @classmethod + def is_in_user_playlist(cls, user, podcast): + """ + Check if a podcast is in a user's playlist and active + + Args: + user: User instance + podcast: Podcast instance + + Returns: + Boolean indicating if the podcast is in the user's playlist and active + """ + return cls.objects.filter( + user=user, + podcast=podcast, + status=True + ).exists() + diff --git a/apps/podcast/serializers.py b/apps/podcast/serializers.py new file mode 100755 index 0000000..e50547a --- /dev/null +++ b/apps/podcast/serializers.py @@ -0,0 +1,325 @@ +from rest_framework import serializers +from utils import get_thumbs +from apps.podcast.models import * +from apps.bookmark.serializers import * + + +class PodcastCategoryListSerializer(serializers.ModelSerializer): + playlist_count = serializers.SerializerMethodField() + + class Meta: + model = PodcastCategory + fields = ['id', 'title', 'slug', 'playlist_count'] + + def get_playlist_count(self, obj): + return obj.playlists.filter(status=True).count() + + +class PodcastListSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + audio_file = serializers.SerializerMethodField() + in_user_playlist = serializers.SerializerMethodField() + + class Meta: + model = Podcast + fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'audio_file', + 'audio_time', 'view_count', 'created_at', 'in_user_playlist'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_audio_file(self, obj): + """Get full URL for audio file if it exists""" + if obj.audio_file: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.audio_file.url) + return obj.audio_file.url + return None + + def get_in_user_playlist(self, obj): + """ + Check if the podcast is in the user's personal playlist. + Returns True if the podcast is in the user's playlist and active, False otherwise. + """ + request = self.context.get('request') + user = request.user if request and request.user.is_authenticated else None + + if not user: + return False + + return UserPlaylist.is_in_user_playlist(user, obj) + + +class PodcastDetailSerializer(serializers.ModelSerializer): + categories = PodcastCategoryListSerializer(many=True, read_only=True) + thumbnail = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() + user_rate = serializers.SerializerMethodField() + average_rate = serializers.SerializerMethodField() + is_in_playlist = serializers.SerializerMethodField() + playlist_podcasts = serializers.SerializerMethodField() + in_user_playlist = serializers.SerializerMethodField() + + class Meta: + model = Podcast + fields = ['id', 'title', 'slug', 'thumbnail', 'description', + 'audio_file', 'audio_time', 'view_count', 'download_count', + 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', + 'is_in_playlist', 'playlist_podcasts', 'in_user_playlist'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_bookmark(self, obj): + """ + Get bookmark information for this podcast. + """ + # 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='podcast' + ) + return book_mark.get('is_bookmarked', False) + + def get_user_rate(self, obj): + """ + Get rate information for this podcast 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='podcast', + content_id=obj.id + ) + + return rate_info + + def get_average_rate(self, obj): + """ + Get the average rate for this podcast. + """ + from apps.bookmark.models.rate import Rate + + # Get average rate information using the Rate model + return Rate.get_average_rate( + service='podcast', + content_id=obj.id + ) + + def get_is_in_playlist(self, obj): + """ + Check if the podcast is in any playlist. + Returns True if the podcast is in at least one playlist, False otherwise. + """ + return PlaylistItem.objects.filter(podcast=obj).exists() + + def get_playlist_podcasts(self, obj): + """ + If the podcast is in a playlist, return all podcasts from the first playlist it belongs to, + excluding the current podcast itself. Podcasts are ordered by their priority in the playlist. + Returns null if the podcast is not in any playlist. + """ + # Check if the podcast is in any playlist + if not self.get_is_in_playlist(obj): + return None + + # Get the first playlist that contains this podcast + playlist_item = PlaylistItem.objects.filter(podcast=obj).first() + if not playlist_item: + return None + + playlist = playlist_item.playlist + + # Get all podcasts in this playlist except the current one, ordered by priority + playlist_podcasts = Podcast.objects.filter( + playlist_appearances__playlist=playlist + ).exclude( + id=obj.id + ).distinct().order_by('playlist_appearances__priority') + + # Serialize the podcasts + return PodcastListSerializer( + playlist_podcasts, + many=True, + context=self.context + ).data + + def get_in_user_playlist(self, obj): + """ + Check if the podcast is in the user's personal playlist. + Returns True if the podcast is in the user's playlist and active, False otherwise. + """ + request = self.context.get('request') + user = request.user if request and request.user.is_authenticated else None + + if not user: + return False + + return UserPlaylist.is_in_user_playlist(user, obj) + + +class PinnedPodcastCollectionSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = PodcastCollection + fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + +class PodcastPlaylistListSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + total_time_formatted = serializers.SerializerMethodField() + episodes_count = serializers.SerializerMethodField() + + class Meta: + model = PodcastPlaylist + fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'view_count', 'total_time_formatted', 'episodes_count', 'order', 'created_at'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_total_time_formatted(self, obj): + """Format total_time as HH:MM:SS string""" + if obj.total_time: + total_seconds = int(obj.total_time.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return "00:00:00" + + def get_episodes_count(self, obj): + """Return the number of episodes (podcasts) in this playlist""" + return obj.playlist_items.count() + + +class PodcastPlaylistDetailSerializer(serializers.ModelSerializer): + categories = PodcastCategoryListSerializer(many=True, read_only=True) + thumbnail = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() + user_rate = serializers.SerializerMethodField() + average_rate = serializers.SerializerMethodField() + podcasts = serializers.SerializerMethodField() + total_time_formatted = serializers.SerializerMethodField() + + class Meta: + model = PodcastPlaylist + fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'description', + 'view_count', 'total_time_formatted', 'order', 'status', + 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', 'podcasts'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_total_time_formatted(self, obj): + """Format total_time as HH:MM:SS string""" + if obj.total_time: + total_seconds = int(obj.total_time.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return "00:00:00" + + def get_bookmark(self, obj): + """Get bookmark information for this playlist.""" + request = self.context.get('request') + user = request.user if request else None + book_mark = BookmarkStatusSerializer.get_bookmark_info( + obj=obj, + user=user, + service='podcast_playlist' + ) + return book_mark.get('is_bookmarked', False) + + def get_user_rate(self, obj): + """Get rate information for this playlist from the current user.""" + from apps.bookmark.models.rate import Rate + + 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 + } + + rate_info = Rate.get_user_rate( + user=user, + service='podcast_playlist', + content_id=obj.id + ) + + return rate_info + + def get_average_rate(self, obj): + """Get the average rate for this playlist.""" + from apps.bookmark.models.rate import Rate + + return Rate.get_average_rate( + service='podcast_playlist', + content_id=obj.id + ) + + def get_podcasts(self, obj): + """Get all podcasts in this playlist ordered by priority.""" + podcasts = Podcast.objects.filter( + playlist_appearances__playlist=obj + ).distinct().order_by('playlist_appearances__priority') + + return PodcastListSerializer( + podcasts, + many=True, + context=self.context + ).data + + +class MiddlePodcastCollectionSerializer(serializers.ModelSerializer): + playlists = serializers.SerializerMethodField() + + class Meta: + model = PodcastCollection + fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'pin_top', 'playlists') + + def get_playlists(self, obj): + playlists = obj.related_playlists.filter(status=True).order_by('order', '-created_at') + return PodcastPlaylistListSerializer(playlists, many=True, context=self.context).data + + +class UserPlaylistSerializer(serializers.ModelSerializer): + class Meta: + model = UserPlaylist + fields = ('id', 'podcast', 'status', 'created_at', 'updated_at') + read_only_fields = ('id', 'created_at', 'updated_at') + + +class UserPlaylistCreateSerializer(serializers.Serializer): + podcast_id = serializers.IntegerField() + status = serializers.BooleanField(default=True) + + def validate_podcast_id(self, value): + try: + podcast = Podcast.objects.get(id=value, status=True) + return value + except Podcast.DoesNotExist: + raise serializers.ValidationError("Podcast with this ID does not exist or is not active.") \ No newline at end of file diff --git a/apps/podcast/urls.py b/apps/podcast/urls.py new file mode 100755 index 0000000..6a45e13 --- /dev/null +++ b/apps/podcast/urls.py @@ -0,0 +1,17 @@ +from django.urls import path, re_path +from .views import * + +app_name = 'podcast' + +urlpatterns = [ + path('categories/', PodcastCategoryListAPIView.as_view(), name='category-list'), + path('pinned-collections/', PinnedPodcastCollectionListView.as_view(), name='pinned-collection-list'), + path('collections/', MiddlePodcastCollectionListView.as_view(), name='collection-list'), + + path('list/', PodcastListAPIView.as_view(), name='podcast-list'), + re_path(r'detail/(?P[\w-]+)/$', PodcastDetailAPIView.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'), +] \ No newline at end of file diff --git a/apps/podcast/views.py b/apps/podcast/views.py index 91ea44a..abb87b7 100644 --- a/apps/podcast/views.py +++ b/apps/podcast/views.py @@ -1,3 +1,331 @@ -from django.shortcuts import render +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 -# Create your views here. + +from apps.podcast.models import * +from apps.podcast.serializers import * + + +class PodcastCategoryListAPIView(generics.ListAPIView): + """ + API view to list all podcast categories + """ + serializer_class = PodcastCategoryListSerializer + + @swagger_auto_schema( + operation_description="Get a list of all active podcast categories", + tags=["Dobodbi - Podcast"], + responses={ + 200: openapi.Response( + description="List of podcast categories", + schema=PodcastCategoryListSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return PodcastCategory.objects.filter(status=True).order_by('order') + + + +class PinnedPodcastCollectionListView(generics.ListAPIView): + serializer_class = PinnedPodcastCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = NoPagination + + @swagger_auto_schema( + operation_description="Get a list of pinned podcast collections", + tags=["Dobodbi - Podcast"], + responses={ + 200: openapi.Response( + description="List of pinned podcast collections", + schema=PinnedPodcastCollectionSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return PinnedPodcastCollection.objects.filter( + status=True, + display_position=PodcastCollection.DisplayPosition.PINNED + ).order_by('-order', '-id') + + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + categories_count = PodcastCategory.objects.filter(status=True).count() + # Count podcasts in the user's playlist + user_playlist_count = 0 + if request.user.is_authenticated: + user_playlist_count = UserPlaylist.objects.filter( + user=request.user, + status=True + ).count() + + info = { + "categories_count": categories_count, + "user_playlist_count": user_playlist_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 MiddlePodcastCollectionListView(generics.ListAPIView): + serializer_class = MiddlePodcastCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = NoPagination + + @swagger_auto_schema( + operation_description="Get a list of middle podcast collections", + tags=["Dobodbi - Podcast"], + responses={ + 200: openapi.Response( + description="List of middle podcast collections", + schema=MiddlePodcastCollectionSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return PodcastCollection.objects.filter( + status=True, + display_position=PodcastCollection.DisplayPosition.MIDDLE + ).order_by('order') + + +class PodcastListAPIView(generics.ListAPIView): + """ + API view to list all podcast playlists, with optional filtering by category, collection + """ + serializer_class = PodcastPlaylistListSerializer + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_description="Get a list of podcast playlists with optional filtering and sorting", + tags=["Dobodbi - Podcast"], + manual_parameters=[ + openapi.Parameter( + name='category', + in_=openapi.IN_QUERY, + description='Filter playlists 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 playlists by collection slug', + type=openapi.TYPE_STRING, + required=False + ), + openapi.Parameter( + name='is_bookmark', + in_=openapi.IN_QUERY, + description='Filter playlists that are bookmarked by the user (true/false)', + type=openapi.TYPE_BOOLEAN, + required=False + ), + openapi.Parameter( + name='search', + in_=openapi.IN_QUERY, + description='Search playlists by title', + type=openapi.TYPE_STRING, + required=False + ), + openapi.Parameter( + name='sort', + in_=openapi.IN_QUERY, + description='Sort playlists by field. Options: created_at, -created_at, view_count, -view_count, title, -title, order, -order', + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + 200: openapi.Response( + description="List of podcast playlists with episodes count", + schema=PodcastPlaylistListSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = PodcastPlaylist.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: + 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': + from apps.bookmark.models import Bookmark + + bookmarked_ids = Bookmark.objects.filter( + user=self.request.user, + service=Bookmark.ServiceChoices.PODCAST_PLAYLIST, + status=True + ).values_list('content_id', flat=True) + + 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', 'order', '-order' + ] + if sort in allowed_sorts: + queryset = queryset.order_by(sort) + else: + queryset = queryset.order_by('-created_at') + + return queryset + + +class PodcastDetailAPIView(generics.RetrieveAPIView): + """ + API view to retrieve details of a specific podcast playlist + """ + serializer_class = PodcastPlaylistDetailSerializer + lookup_field = 'slug' + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_description="Get podcast playlist details by slug", + tags=["Dobodbi - Podcast"], + responses={ + 200: openapi.Response( + description="Podcast playlist details", + schema=PodcastPlaylistDetailSerializer() + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return PodcastPlaylist.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) + + +class UserPlaylistListAPIView(generics.ListAPIView): + """ + API view to list all podcasts in the user's personal playlist + """ + serializer_class = PodcastListSerializer + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_description="Get a list of podcasts in the user's personal playlist", + tags=["Dobodbi - Podcast"], + responses={ + 200: openapi.Response( + description="List of podcasts in the user's playlist", + schema=PodcastListSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + # Get all active podcasts that are in the user's playlist + user_playlist_podcasts = UserPlaylist.objects.filter( + user=self.request.user, + status=True + ).values_list('podcast_id', flat=True) + + return Podcast.objects.filter( + id__in=user_playlist_podcasts, + status=True + ).order_by('-created_at') + + +class UserPlaylistCreateAPIView(generics.CreateAPIView): + """ + API view to add or update a podcast in a user's personal playlist + """ + serializer_class = UserPlaylistCreateSerializer + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_description="Add or update a podcast in the user's personal playlist", + tags=["Dobodbi - Podcast"], + request_body=UserPlaylistCreateSerializer, + responses={ + 201: openapi.Response( + description="Podcast added to playlist successfully", + schema=UserPlaylistSerializer() + ), + 400: "Bad Request" + } + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + podcast_id = serializer.validated_data['podcast_id'] + playlist_status = serializer.validated_data.get('status', True) + + try: + podcast = Podcast.objects.get(id=podcast_id, status=True) + except Podcast.DoesNotExist: + return Response( + {"detail": "Podcast not found or not active."}, + status=status.HTTP_404_NOT_FOUND + ) + + # Try to get existing user playlist entry or create a new one + user_playlist, created = UserPlaylist.objects.update_or_create( + user=request.user, + podcast=podcast, + defaults={'status': playlist_status} + ) + + # Return the user playlist entry + response_serializer = UserPlaylistSerializer(user_playlist) + return Response( + response_serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) diff --git a/apps/quiz/admin/participant.py b/apps/quiz/admin/participant.py index 8775a8f..9f9d2d1 100644 --- a/apps/quiz/admin/participant.py +++ b/apps/quiz/admin/participant.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from ajaxdatatable.admin import AjaxDatatable from django.contrib import admin from django.db.models import F, Q @@ -16,6 +17,29 @@ class ParticipantAnswerInline(admin.StackedInline): ) def _correct_answer(self, obj): +======= +from django.contrib import admin +from django.db.models import F +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + +from unfold.admin import ModelAdmin, StackedInline +from unfold.decorators import display + +from apps.quiz.models import QuizParticipant, ParticipantAnswer +from apps.account.models import User + +from utils.admin import project_admin_site + +class ParticipantAnswerInline(StackedInline): + model = ParticipantAnswer + readonly_fields = ( + 'correct_answer_display', 'question', 'at_time', 'answer_timing', + ) + + @display(description="Correct Answer") + def correct_answer_display(self, obj): +>>>>>>> develop return obj.correct_answer def has_add_permission(self, request, obj): @@ -28,8 +52,11 @@ class ParticipantAnswerInline(admin.StackedInline): return super().get_queryset(request).annotate(correct_answer=F('question__correct_answer')) +<<<<<<< HEAD +======= +>>>>>>> develop class UserEmailFilter(SimpleListFilter): title = _('User Email') parameter_name = 'user_email' @@ -45,6 +72,7 @@ class UserEmailFilter(SimpleListFilter): return queryset +<<<<<<< HEAD @admin.register(QuizParticipant) class ParticipantAdmin(AjaxDatatable): inlines = [ParticipantAnswerInline] @@ -59,3 +87,19 @@ class ParticipantAdmin(AjaxDatatable): +======= +class ParticipantAdmin(ModelAdmin): + inlines = [ParticipantAnswerInline] + search_fields = ['user__username', 'user__fullname'] + list_display = [ + 'quiz', 'user', 'started_at', 'ended_at', 'total_timing', + 'question_score', 'timing_score', 'total_score' + ] + list_filter = ['started_at', 'ended_at', 'quiz__status', UserEmailFilter] + + # Optional: Add these for better UI experience + date_hierarchy = 'started_at' + ordering = ['-started_at'] + +project_admin_site.register(QuizParticipant, ParticipantAdmin) +>>>>>>> develop diff --git a/apps/quiz/admin/question.py b/apps/quiz/admin/question.py index a46e063..8135229 100644 --- a/apps/quiz/admin/question.py +++ b/apps/quiz/admin/question.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from ajaxdatatable.admin import AjaxDatatable from django import forms from django.contrib import admin @@ -17,12 +18,33 @@ class QuestionAdminForm(forms.ModelForm): # @admin.register(Question) # class QuestionAdmin(AjaxDatatable): +======= +from django import forms +from django.contrib import admin + +from unfold.admin import ModelAdmin, TabularInline, StackedInline +from unfold.forms import forms + +from apps.quiz.models import Question + +from utils.admin import project_admin_site + + + +# Uncomment if you want to register Question as a standalone admin +# @admin.register(Question) +# class QuestionAdmin(ModelAdmin): +>>>>>>> develop # list_display = ('question', 'correct_answer', 'quiz', 'priority') # form = QuestionAdminForm # ordering = ("priority", "id",) # fieldsets = ( # ( +<<<<<<< HEAD # '', { +======= +# None, { +>>>>>>> develop # 'fields': ( # 'question', # ('option1', 'option2'), @@ -32,11 +54,16 @@ class QuestionAdminForm(forms.ModelForm): # }, # ), # ( +<<<<<<< HEAD # '', { +======= +# None, { +>>>>>>> develop # 'fields': ('priority',) # } # ) # ) +<<<<<<< HEAD class QuestionAdminInline(admin.StackedInline): model = Question @@ -47,6 +74,42 @@ class QuestionAdminInline(admin.StackedInline): fieldsets = ( ( '', { +======= +@admin.register(Question) +class QuestionAdmin(ModelAdmin): + list_display = ('question', 'correct_answer', 'quiz', 'priority') + ordering = ("priority", "id",) + search_fields = ('question', 'quiz__title') + list_filter = ('quiz',) + + fieldsets = ( + ( + None, { + 'fields': ( + 'quiz', + 'question', + ('option1', 'option2'), + ('option3', 'option4'), + 'correct_answer', + ) + }, + ), + ( + None, { + 'fields': ('priority',) + } + ) + ) + +class QuestionAdminInline(StackedInline): + model = Question + ordering = ("priority", "id",) + extra = 1 + + fieldsets = ( + ( + None, { +>>>>>>> develop 'fields': ( 'question', ('option1', 'option2'), @@ -56,8 +119,17 @@ class QuestionAdminInline(admin.StackedInline): }, ), ( +<<<<<<< HEAD '', { +======= + None, { +>>>>>>> develop 'fields': ('priority',) } ) ) +<<<<<<< HEAD +======= +project_admin_site.register(Question, QuestionAdmin) + +>>>>>>> develop diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py index 9520416..a17bbe1 100644 --- a/apps/quiz/admin/quiz.py +++ b/apps/quiz/admin/quiz.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from ajaxdatatable.admin import AjaxDatatable from django.contrib import admin from django.db.models import Count @@ -52,3 +53,77 @@ class QuizAdmin(AjaxDatatable): return super().get_queryset(request).annotate( questions_count=Count('questions') ) +======= +from django.contrib import admin +from django.db.models import Count +from django.utils.safestring import mark_safe +from django.urls import reverse + +from unfold.admin import ModelAdmin +from unfold.decorators import display + +from apps.course.models import CourseLesson +from apps.quiz.models import Quiz +from apps.quiz.admin.question import QuestionAdminInline +from utils.admin import project_admin_site + + + +class QuizAdmin(ModelAdmin): + search_fields = ['title', 'lesson__title'] + list_display = ['title', 'description', 'lesson', 'each_question_timing', 'status_display', 'questions_display'] + list_filter = ['each_question_timing', 'status'] + inlines = [QuestionAdminInline] + compressed_fields = True + + + def get_queryset(self, request): + queryset = super().get_queryset(request).annotate( + questions_count=Count('questions') + ) + + # اولویت اول: staff یا admin - دسترسی کامل + if (request.user.is_staff or + request.user.has_role('admin') or + request.user.has_role('super_admin')): + return queryset + + # اولویت دوم: professor - فقط کوئیزهای دوره‌های خود + if request.user.has_role('professor'): + return queryset.filter(lesson__course__professor=request.user) + + return queryset.none() + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + + # محدود کردن انتخاب lesson بر اساس سطح دسترسی کاربر + if (request.user.is_staff or + request.user.has_role('admin') or + request.user.has_role('super_admin')): + # اولویت اول: staff یا admin - دسترسی کامل + form.base_fields['lesson'].queryset = CourseLesson.objects.all() + elif request.user.has_role('professor'): + # اولویت دوم: professor - فقط CourseLesson های دوره‌های خود + form.base_fields['lesson'].queryset = CourseLesson.objects.filter(course__professor=request.user) + else: + # سایر کاربران - عدم دسترسی + form.base_fields['lesson'].queryset = CourseLesson.objects.none() + + form.base_fields['lesson'].widget.can_add_related = False + + return form + + @display(description='Status', ordering='status') + def status_display(self, obj): + if obj.status: + return mark_safe('Active') + return mark_safe('Inactive') + + @display(description='Questions', ordering='questions_count') + def questions_display(self, obj): + url = reverse('admin:quiz_question_changelist') + f'?quiz={obj.id}' + return mark_safe(f'Questions: {obj.questions_count}') + +project_admin_site.register(Quiz, QuizAdmin) +>>>>>>> develop diff --git a/apps/quiz/management/__init__.py b/apps/quiz/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/management/commands/__init__.py b/apps/quiz/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/management/commands/clear_quiz_data.py b/apps/quiz/management/commands/clear_quiz_data.py new file mode 100644 index 0000000..c3a0192 --- /dev/null +++ b/apps/quiz/management/commands/clear_quiz_data.py @@ -0,0 +1,78 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.quiz.models import Quiz, Question, QuizParticipant, ParticipantAnswer + + +class Command(BaseCommand): + help = 'Clear all quiz-related data from the database' + + def add_arguments(self, parser): + parser.add_argument( + '--confirm', + action='store_true', + help='Confirm that you want to delete all quiz data', + ) + + def handle(self, *args, **options): + if not options['confirm']: + self.stdout.write( + self.style.WARNING( + 'This command will delete ALL quiz-related data from the database!\n' + 'This includes:\n' + '- All Quizzes\n' + '- All Questions\n' + '- All Quiz Participants\n' + '- All Participant Answers\n\n' + 'Use --confirm flag to proceed with deletion.\n' + 'Example: python manage.py clear_quiz_data --confirm' + ) + ) + return + + try: + with transaction.atomic(): + # Count records before deletion + participant_answers_count = ParticipantAnswer.objects.count() + quiz_participants_count = QuizParticipant.objects.count() + questions_count = Question.objects.count() + quizzes_count = Quiz.objects.count() + + self.stdout.write( + f'Found {participant_answers_count} participant answers, ' + f'{quiz_participants_count} quiz participants, ' + f'{questions_count} questions, and ' + f'{quizzes_count} quizzes.' + ) + + # Delete in order to respect foreign key constraints + # ParticipantAnswer -> QuizParticipant -> Quiz + # Question -> Quiz + + self.stdout.write('Deleting participant answers...') + ParticipantAnswer.objects.all().delete() + + self.stdout.write('Deleting quiz participants...') + QuizParticipant.objects.all().delete() + + self.stdout.write('Deleting questions...') + Question.objects.all().delete() + + self.stdout.write('Deleting quizzes...') + Quiz.objects.all().delete() + + self.stdout.write( + self.style.SUCCESS( + f'Successfully deleted all quiz data:\n' + f'- {participant_answers_count} participant answers\n' + f'- {quiz_participants_count} quiz participants\n' + f'- {questions_count} questions\n' + f'- {quizzes_count} quizzes' + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Error occurred while clearing quiz data: {str(e)}') + ) + raise \ No newline at end of file diff --git a/apps/quiz/migrations/0001_initial.py b/apps/quiz/migrations/0001_initial.py index 44ea814..229ee40 100644 --- a/apps/quiz/migrations/0001_initial.py +++ b/apps/quiz/migrations/0001_initial.py @@ -1,8 +1,16 @@ +<<<<<<< HEAD # Generated by Django 3.2.4 on 2024-11-29 11:00 from django.conf import settings from django.db import migrations, models import django.db.models.deletion +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -10,13 +18,20 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD ('course', '0005_participant_unread_messages_count'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('account', '0004_user_skill'), +======= + ('account', '0001_initial'), + ('course', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), +>>>>>>> develop ] operations = [ migrations.CreateModel( +<<<<<<< HEAD name='Quiz', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -32,6 +47,8 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +======= +>>>>>>> develop name='QuizRankUser', fields=[ ], @@ -45,6 +62,7 @@ class Migration(migrations.Migration): bases=('account.user',), ), migrations.CreateModel( +<<<<<<< HEAD name='QuizParticipant', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -60,6 +78,20 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'Participant', 'verbose_name_plural': 'Participants', +======= + name='Quiz', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='Quiz Title', max_length=255, verbose_name='title')), + ('description', models.CharField(blank=True, max_length=55, null=True, verbose_name='Description')), + ('each_question_timing', models.PositiveIntegerField()), + ('status', models.BooleanField(default=True)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.lesson', verbose_name='lesson')), + ], + options={ + 'verbose_name': 'Quiz', + 'verbose_name_plural': 'Quizzes', +>>>>>>> develop 'ordering': ('-id',), }, ), @@ -84,14 +116,41 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +<<<<<<< HEAD +======= + name='QuizParticipant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(verbose_name='started at')), + ('ended_at', models.DateTimeField(verbose_name='ended at')), + ('total_timing', models.PositiveIntegerField(help_text='Seconds take to finish the quiz')), + ('question_score', models.PositiveIntegerField()), + ('timing_score', models.PositiveIntegerField()), + ('total_score', models.PositiveIntegerField()), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='quiz.quiz')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uquizzes', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'Participant', + 'verbose_name_plural': 'Participants', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( +>>>>>>> develop name='ParticipantAnswer', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('option_num', models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='selected option')), ('at_time', models.DateTimeField()), ('answer_timing', models.PositiveSmallIntegerField(default=0, verbose_name='seconds take to answer')), +<<<<<<< HEAD ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='quiz.quizparticipant')), ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question')), +======= + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='quiz.quizparticipant')), +>>>>>>> develop ], options={ 'verbose_name': 'User Quiz Answer', diff --git a/apps/quiz/migrations/0002_change_quiz_lesson_to_courselesson.py b/apps/quiz/migrations/0002_change_quiz_lesson_to_courselesson.py new file mode 100644 index 0000000..fe81dbe --- /dev/null +++ b/apps/quiz/migrations/0002_change_quiz_lesson_to_courselesson.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.8 on 2025-08-12 22:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0006_participant_is_active'), + ('quiz', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='quiz', + name='lesson', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.courselesson', verbose_name='lesson'), + ), + ] diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py index 305af43..c796d07 100644 --- a/apps/quiz/models/participant.py +++ b/apps/quiz/models/participant.py @@ -1,4 +1,9 @@ from django.db import models +<<<<<<< HEAD +======= +from django.db.models import F, Window +from django.db.models.functions import Rank +>>>>>>> develop from apps.account.models import User diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py index 26bda6a..21cf049 100644 --- a/apps/quiz/models/quiz.py +++ b/apps/quiz/models/quiz.py @@ -5,7 +5,12 @@ from apps.account.models import User class Quiz(models.Model): +<<<<<<< HEAD lesson = models.ForeignKey("course.Lesson", verbose_name=_('lesson'), related_name='quizzes', on_delete=models.CASCADE) +======= + lesson = models.ForeignKey("course.CourseLesson", verbose_name=_('lesson'), related_name='quizzes', on_delete=models.CASCADE) + +>>>>>>> develop title = models.CharField(max_length=255, verbose_name=_('title'), help_text="Quiz Title") description = models.CharField(max_length=55, blank=True, null=True, verbose_name="Description") each_question_timing = models.PositiveIntegerField() diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py index 2a9a133..5a3538c 100644 --- a/apps/quiz/serializers/quiz.py +++ b/apps/quiz/serializers/quiz.py @@ -1,7 +1,11 @@ from rest_framework import serializers from apps.quiz.models import Question, Quiz, QuizParticipant +<<<<<<< HEAD from apps.course.models import Lesson, Participant +======= +from apps.course.models import Participant +>>>>>>> develop @@ -23,7 +27,17 @@ class QuizListSerializer(serializers.ModelSerializer): return False # Check if the user has participated in this quiz user = request.user +<<<<<<< HEAD course = obj.lesson.course +======= + + # obj.lesson is now CourseLesson directly + course_lesson = obj.lesson + if not course_lesson: + return False + + course = course_lesson.course +>>>>>>> develop if not self._is_participant(user, course): return False @@ -78,5 +92,22 @@ class QuizSerializer(serializers.ModelSerializer): return False # Check if the user has participated in this quiz user = request.user +<<<<<<< HEAD participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() return not participated +======= + + # obj.lesson is now CourseLesson directly + course_lesson = obj.lesson + if not course_lesson: + return False + + course = course_lesson.course + + # Check if user is a participant in the course + if not Participant.objects.filter(student=user, course=course).exists(): + return False + + participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() + return participated +>>>>>>> develop diff --git a/apps/quiz/views/participant.py b/apps/quiz/views/participant.py index 2a0504d..1de79d9 100644 --- a/apps/quiz/views/participant.py +++ b/apps/quiz/views/participant.py @@ -17,6 +17,10 @@ class QuizParticipantCreateAPIView(CreateAPIView): @swagger_auto_schema( operation_description=doc_quiz_submit(), +<<<<<<< HEAD +======= + tags=["Imam-Javad - Quiz"], +>>>>>>> develop ) def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) diff --git a/apps/quiz/views/quiz.py b/apps/quiz/views/quiz.py index 0f42c90..336314e 100644 --- a/apps/quiz/views/quiz.py +++ b/apps/quiz/views/quiz.py @@ -17,6 +17,10 @@ class QuizDetailAPIView(RetrieveAPIView): @swagger_auto_schema( operation_description=doc_quiz_detail(), +<<<<<<< HEAD +======= + tags=["Imam-Javad - Quiz"], +>>>>>>> develop ) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) diff --git a/apps/transaction/admin.py b/apps/transaction/admin.py index 7be51bc..35f70a9 100644 --- a/apps/transaction/admin.py +++ b/apps/transaction/admin.py @@ -1,37 +1,159 @@ from django.contrib import admin - -from apps.transaction.models import TransactionParticipant, ParticipantInfo from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html +from django.contrib import messages + +from unfold.admin import ModelAdmin, StackedInline, TabularInline +from unfold.decorators import display +from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt +from apps.course.models import Participant +from utils.admin import project_admin_site -class ParticipantInfoInline(admin.StackedInline): +class ParticipantInfoInline(StackedInline): model = ParticipantInfo - extra = 1 + extra = 1 fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate'] - # readonly_fields = ['email', 'phone_number'] - classes = ['collapse'] - - - - + # readonly_fields = ['email', 'phone_number'] + classes = ['collapse'] + tab = True + show_change_link = True + + +class TransactionReceiptInline(TabularInline): + model = TransactionReceipt + extra = 0 + fields = ['file', 'description', 'uploaded_at'] + readonly_fields = ['uploaded_at'] + classes = ['collapse'] + tab = True + show_change_link = True + verbose_name = _('Payment Receipt') + verbose_name_plural = _('Payment Receipts') + + @admin.register(TransactionParticipant) -class TransactionParticipantAdmin(admin.ModelAdmin): - list_display = ('user', 'course', 'is_paid', 'price', 'created_at', 'updated_at') - list_filter = ('is_paid', 'course', 'created_at') +class TransactionParticipantAdmin(ModelAdmin): + list_display = ('user', 'course', 'payment_status', 'price_display', 'participant_status', 'receipts_count', 'created_at', 'updated_at') + list_filter = ('status', 'course', 'created_at') search_fields = ('user__email', 'course__title') - readonly_fields = ['user', 'course', 'price', 'created_at', 'updated_at'] - inlines = [ParticipantInfoInline] + readonly_fields = ['created_at', 'updated_at'] + inlines = [ParticipantInfoInline, TransactionReceiptInline] + autocomplete_fields = ['user',] + show_change_link = True ordering = ('-created_at',) - list_filter = ('is_paid', 'course', 'created_at') fieldsets = ( (None, { - 'fields': ('user', 'course', 'is_paid', 'price') + 'fields': ('user', 'course', 'status', 'price') }), (_('Timestamps'), { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) + + @display(description=_("Payment Status"), ordering="status") + def payment_status(self, obj): + if obj.status == 'success': + return format_html('Paid') + elif obj.status == 'failed': + return format_html('Failed') + elif obj.status == 'waiting_approval': + return format_html('Waiting Approval') + return format_html('Pending') + + @display(description=_("Receipts Count")) + def receipts_count(self, obj): + """Display count of uploaded receipts""" + count = obj.receipts.count() + if count > 0: + return format_html('{} receipts', count) + return format_html('No receipts') + + @display(description=_("Price"), ordering="price") + def price_display(self, obj): + return format_html('${}', obj.price) + + @display(description=_("Course Participant Status")) + def participant_status(self, obj): + """نمایش وضعیت شرکت‌کننده در دوره""" + if obj.status == TransactionParticipant.TransactionStatus.SUCCESS: + participant_exists = Participant.objects.filter( + student=obj.user, + course=obj.course + ).exists() + if participant_exists: + return format_html('✓ Enrolled') + else: + return format_html('⚠ Not Enrolled') + else: + return format_html('- Not Applicable') + + def save_model(self, request, obj, form, change): + """Override save_model to show messages when participant is created""" + if change: + # Store the old status before saving + old_obj = TransactionParticipant.objects.get(pk=obj.pk) + old_status = old_obj.status + + # Save the object + super().save_model(request, obj, form, change) + + # Check if status changed to SUCCESS + if (old_status != TransactionParticipant.TransactionStatus.SUCCESS and + obj.status == TransactionParticipant.TransactionStatus.SUCCESS): + + participant_exists = Participant.objects.filter( + student=obj.user, + course=obj.course + ).exists() + + if participant_exists: + messages.success( + request, + f"Transaction status updated to SUCCESS. User {obj.user.email} is now enrolled in course '{obj.course.title}'." + ) + else: + messages.warning( + request, + f"Transaction status updated to SUCCESS, but there was an issue enrolling user {obj.user.email} in course '{obj.course.title}'. Please check the logs." + ) + else: + super().save_model(request, obj, form, change) + + def get_queryset(self, request): + # Filter out deleted transactions + return super().get_queryset(request).filter(is_deleted=False) + +project_admin_site.register(TransactionParticipant, TransactionParticipantAdmin) + + +@admin.register(TransactionReceipt) +class TransactionReceiptAdmin(ModelAdmin): + list_display = ('transaction', 'file', 'uploaded_at', 'description_preview') + list_filter = ('uploaded_at', 'transaction__status') + search_fields = ('transaction__user__email', 'transaction__course__title', 'description') + readonly_fields = ['uploaded_at'] + autocomplete_fields = ['transaction'] + ordering = ('-uploaded_at',) + + fieldsets = ( + (None, { + 'fields': ('transaction', 'file', 'description') + }), + (_('Timestamps'), { + 'fields': ('uploaded_at',), + 'classes': ('collapse',) + }), + ) + + @display(description=_("Description")) + def description_preview(self, obj): + """Display truncated description""" + if obj.description: + return obj.description[:50] + '...' if len(obj.description) > 50 else obj.description + return '-' +project_admin_site.register(TransactionReceipt, TransactionReceiptAdmin) diff --git a/apps/transaction/apps.py b/apps/transaction/apps.py index 8cf8565..d10da14 100644 --- a/apps/transaction/apps.py +++ b/apps/transaction/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class TransactionConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.transaction' + + def ready(self): + import apps.transaction.signals diff --git a/apps/transaction/doc.py b/apps/transaction/doc.py new file mode 100644 index 0000000..1eacff9 --- /dev/null +++ b/apps/transaction/doc.py @@ -0,0 +1,711 @@ +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework import status + +def doc_upload_transaction_receipts(): + return """ +# 🐈 Scenario +🛠️ آپلود رسید پرداخت برای تراکنش + +این API برای آپلود یک یا چند رسید پرداخت برای یک تراکنش استفاده می‌شود. +پس از آپلود موفقیت‌آمیز، وضعیت تراکنش به 'waiting_approval' (در انتظار تایید) تغییر می‌کند. + +--- + +## 🚀 روند آپلود (دو مرحله‌ای) + +### مرحله 1️⃣: آپلود فایل به سرور موقت +ابتدا باید فایل‌های خود را به endpoint زیر آپلود کنید: + +``` +POST /upload-tmp-media/ +Content-Type: multipart/form-data + +Body: +- file: [فایل رسید] +``` + +**پاسخ:** +```json +{ + "url": "/static/tmp/xyz123-receipt.jpg", + "name": "receipt.jpg", + "size": "1024000", + "mime_type": "image/jpeg" +} +``` + +### مرحله 2️⃣: ثبت URL فایل‌ها در تراکنش +سپس URL های دریافتی را به این endpoint ارسال کنید: + +``` +POST /api/transactions//receipts/upload/ +Content-Type: application/json +``` + +--- + +## 🚀 درخواست API (مرحله 2) + +### URL: +``` +POST /api/transactions//receipts/upload/ +``` + +### پارامترهای URL: +| کلید | نوع داده | توضیحات | +|------------------|-----------|----------------------------------------------------------| +| `transaction_id` | Integer | شناسه تراکنش که می‌خواهید رسید برای آن ثبت کنید | + +### پارامترهای درخواست (JSON Body): +| کلید | نوع داده | الزامی | توضیحات | +|---------------|-----------|--------|----------------------------------------------------------| +| `files` | String[] | بله | لیست URL های فایل‌های آپلود شده از مرحله 1 (حداکثر 10 فایل) | +| `description` | String | خیر | توضیحات اختیاری درباره رسیدها | + +--- + +## 💡 نکات مهم: +1. **روند دو مرحله‌ای**: + - **مرحله 1**: ابتدا فایل‌ها را به `/upload-tmp-media/` آپلود کنید + - **مرحله 2**: سپس URL های دریافتی را به این API ارسال کنید + +2. **محدودیت فایل‌ها**: + - حداکثر 10 فایل می‌توانید در هر درخواست ثبت کنید + +3. **وضعیت تراکنش**: + - فقط می‌توانید برای تراکنش‌هایی با وضعیت 'pending' یا 'waiting_approval' رسید آپلود کنید + - پس از ثبت موفقیت‌آمیز، وضعیت تراکنش به 'waiting_approval' تغییر می‌کند + +4. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + - فقط می‌توانید برای تراکنش‌های خودتان رسید آپلود کنید + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - رسیدها با موفقیت ثبت شدند | +| `400` | داده‌های نامعتبر یا تراکنش قادر به دریافت رسید نیست | +| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید | +| `404` | تراکنش یافت نشد | + +--- + +## 📄 نمونه درخواست کامل (JSON): + +```json +{ + "files": [ + "/static/tmp/xyz123-receipt1.jpg", + "/static/tmp/abc456-receipt2.jpg" + ], + "description": "Payment receipt for Python course" +} +``` + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "success": true, + "message": "Receipts uploaded successfully", + "transaction_status": "waiting_approval", + "receipts": [ + { + "id": 1, + "file": "http://example.com/media/receipts/1/receipt1.jpg", + "description": "Payment receipt for course enrollment", + "uploaded_at": "2025-12-03T10:30:00Z" + }, + { + "id": 2, + "file": "http://example.com/media/receipts/1/receipt2.jpg", + "description": "Payment receipt for course enrollment", + "uploaded_at": "2025-12-03T10:30:05Z" + } + ] +} +``` + +--- + +## 📄 نمونه درخواست کامل (cURL): + +### مرحله 1 - آپلود فایل: +```bash +curl -X POST \\ + 'http://your-api.com/upload-tmp-media/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ + -F 'file=@/path/to/receipt1.jpg' +``` + +### مرحله 2 - ثبت رسید: +```bash +curl -X POST \\ + 'http://your-api.com/api/transactions/123/receipts/upload/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "files": ["/static/tmp/xyz123-receipt1.jpg"], + "description": "Payment receipt for Python course" + }' +``` + +--- + +## 📄 نمونه پاسخ خطا (403 - عدم دسترسی): + +```json +{ + "message": "You don't have permission to upload receipts for this transaction" +} +``` + +--- + +## 📄 نمونه پاسخ خطا (400 - وضعیت نامعتبر): + +```json +{ + "message": "Cannot upload receipts for transaction with status 'success'" +} +``` +""" + + +def doc_list_transaction_receipts(): + return """ +# 🐈 Scenario +🛠️ لیست رسیدهای پرداخت یک تراکنش + +این API برای دریافت لیست تمام رسیدهای آپلود شده برای یک تراکنش خاص استفاده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +GET /api/transactions//receipts/ +``` + +### پارامترهای URL: +| کلید | نوع داده | توضیحات | +|------------------|-----------|----------------------------------------------------------| +| `transaction_id` | Integer | شناسه تراکنش که می‌خواهید رسیدهای آن را مشاهده کنید | + +--- + +## 💡 نکات مهم: +1. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + - فقط می‌توانید رسیدهای تراکنش‌های خودتان را مشاهده کنید + +2. **مرتب‌سازی**: + - رسیدها بر اساس تاریخ آپلود (جدیدترین اول) مرتب می‌شوند + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیست رسیدها بازگردانده شد | +| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید | +| `404` | تراکنش یافت نشد | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +[ + { + "id": 1, + "file": "http://example.com/media/receipts/1/receipt1.jpg", + "description": "Payment receipt for course enrollment", + "uploaded_at": "2025-12-03T10:30:00Z" + }, + { + "id": 2, + "file": "http://example.com/media/receipts/1/receipt2.jpg", + "description": "Second payment receipt", + "uploaded_at": "2025-12-03T10:25:00Z" + } +] +``` + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|---------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای رسید | +| `file` | String | URL کامل فایل رسید آپلود شده | +| `description` | String | توضیحات اختیاری درباره رسید (ممکن است خالی باشد) | +| `uploaded_at` | DateTime | تاریخ و زمان آپلود رسید | + +--- + +## 📄 نمونه درخواست (cURL): + +```bash +curl -X GET \\ + 'http://your-api.com/api/transactions/123/receipts/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' +``` + +--- + +## 📄 نمونه پاسخ خطا (403 - عدم دسترسی): + +```json +{ + "message": "You don't have permission to view receipts for this transaction" +} +``` + +--- + +## 📄 نمونه پاسخ خطا (404 - تراکنش یافت نشد): + +```json +{ + "message": "Transaction not found" +} +``` +""" + + +def doc_transaction_list(): + return """ +# 🐈 Scenario +🛠️ لیست تراکنش‌های کاربر + +این API برای دریافت لیست تمام تراکنش‌های کاربر احراز هویت شده استفاده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +GET /api/transactions/list/ +``` + +--- + +## 💡 نکات مهم: +1. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + - فقط تراکنش‌های خودتان را مشاهده می‌کنید + +2. **فیلترینگ خودکار**: + - تراکنش‌های حذف شده (soft deleted) نمایش داده نمی‌شوند + +3. **وضعیت‌های تراکنش**: + - `pending`: در انتظار پرداخت + - `waiting_approval`: در انتظار تایید (رسید آپلود شده) + - `success`: پرداخت موفق و تایید شده + - `failed`: پرداخت ناموفق + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - لیست تراکنش‌ها بازگردانده شد | +| `401` | عدم احراز هویت | + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|---------------|------------|----------------------------------------------------------| +| `id` | Integer | شناسه یکتای تراکنش | +| `course` | Object | اطلاعات دوره مرتبط با تراکنش | +| `status` | String | وضعیت تراکنش (pending, waiting_approval, success, failed) | +| `price` | Decimal | مبلغ تراکنش | +| `created_at` | DateTime | تاریخ و زمان ایجاد تراکنش | +| `updated_at` | DateTime | تاریخ و زمان آخرین به‌روزرسانی تراکنش | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +[ + { + "id": 1, + "course": { + "id": 5, + "title": "Python Programming Basics", + "slug": "python-programming-basics", + "thumbnail": "http://example.com/media/courses/thumbnails/python.jpg", + "price": "99.00", + "final_price": "79.00" + }, + "status": "waiting_approval", + "price": "79.00", + "created_at": "2025-12-01T10:00:00Z", + "updated_at": "2025-12-03T10:30:00Z" + }, + { + "id": 2, + "course": { + "id": 8, + "title": "Django Web Development", + "slug": "django-web-development", + "thumbnail": "http://example.com/media/courses/thumbnails/django.jpg", + "price": "149.00", + "final_price": "149.00" + }, + "status": "success", + "price": "149.00", + "created_at": "2025-11-28T14:20:00Z", + "updated_at": "2025-11-29T09:15:00Z" + } +] +``` + +--- + +## 📄 نمونه درخواست (cURL): + +```bash +curl -X GET \\ + 'http://your-api.com/api/transactions/list/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' +``` +""" + + +def doc_create_transaction(): + return """ +# 🐈 Scenario +🛠️ ثبت‌نام در دوره و ایجاد تراکنش + +این API برای ثبت‌نام کاربر در یک دوره و ایجاد تراکنش استفاده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/transactions//join/ +``` + +### پارامترهای URL: +| کلید | نوع داده | توضیحات | +|---------|-----------|----------------------------------------------------------| +| `slug` | String | اسلاگ دوره‌ای که می‌خواهید در آن ثبت‌نام کنید | + +### پارامترهای درخواست (JSON Body): +| کلید | نوع داده | الزامی | توضیحات | +|---------------------|-----------|--------|----------------------------------------------------------| +| `participant_infos` | Array | بله | لیست اطلاعات شرکت‌کنندگان | + +### ساختار `participant_infos`: +| کلید | نوع داده | الزامی | توضیحات | +|---------------|-----------|--------|----------------------------------------------------------| +| `fullname` | String | بله | نام کامل شرکت‌کننده | +| `email` | String | بله | ایمیل شرکت‌کننده (برای دوره رایگان باید با ایمیل کاربر احراز هویت شده یکسان باشد) | +| `phone_number`| String | خیر | شماره تلفن شرکت‌کننده | +| `gender` | String | خیر | جنسیت شرکت‌کننده (male, female) | +| `birthdate` | Date | خیر | تاریخ تولد شرکت‌کننده (فرمت: YYYY-MM-DD) | + +--- + +## 💡 نکات مهم: +1. **دوره رایگان**: + - اگر دوره رایگان باشد و فقط یک شرکت‌کننده در لیست باشد و ایمیل او با کاربر احراز هویت شده یکسان باشد، تراکنش به صورت خودکار تایید می‌شود (status = 'success') + - کاربر به صورت خودکار به عنوان دانشجو در دوره ثبت می‌شود + +2. **دوره پولی**: + - تراکنش با وضعیت 'pending' ایجاد می‌شود + - سیستم بر اساس موقعیت جغرافیایی کاربر، روش پرداخت مناسب را تعیین می‌کند + +3. **روش پرداخت (Payment Method)**: + - **Payment_Gateway**: برای کاربران غیر روسی - پرداخت از طریق درگاه پرداخت آنلاین + - **Receipt**: برای کاربران روسی - آپلود رسید پرداخت از طریق واتس‌اپ + +4. **تشخیص موقعیت جغرافیایی**: + - ابتدا از هدر Cloudflare (`CF-IPCountry`) استفاده می‌شود + - در صورت عدم وجود، از پایگاه داده GeoIP محلی استفاده می‌شود + - کاربران روسی روش پرداخت Receipt دریافت می‌کنند + - کاربران سایر کشورها روش پرداخت Payment_Gateway دریافت می‌کنند + +5. **احراز هویت**: + - باید توکن احراز هویت را در هدر درخواست ارسال کنید + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - تراکنش ایجاد شد | +| `400` | داده‌های نامعتبر | +| `404` | دوره یافت نشد | + +### ساختار پاسخ: +| کلید | نوع داده | توضیحات | +|---------------------|-----------|----------------------------------------------------------| +| `message` | String | پیام موفقیت‌آمیز | +| `transaction_id` | Integer | شناسه تراکنش ایجاد شده | +| `payment_method` | String | روش پرداخت (Payment_Gateway یا Receipt) | +| `payment_link` | String | لینک پرداخت (فقط برای Payment_Gateway) | +| `participant_infos` | Array | لیست اطلاعات شرکت‌کنندگان | + +--- + +## 💳 روش‌های پرداخت: + +### Payment_Gateway (درگاه پرداخت): +- **کاربران**: غیر روسی +- **اقدام کاربر**: کلیک روی `payment_link` و پرداخت آنلاین +- **فرآیند**: پرداخت مستقیم از طریق درگاه پرداخت + +### Receipt (رسید پرداخت): +- **کاربران**: روسی +- **اقدام کاربر**: آپلود رسید پرداخت از طریق واتس‌اپ +- **فرآیند**: آپلود رسید → بررسی توسط ادمین → تایید پرداخت + +--- + +## 📄 نمونه درخواست (JSON Body): + +```json +{ + "participant_infos": [ + { + "fullname": "علی رضایی", + "email": "ali@example.com", + "phone_number": "+989123456789", + "gender": "male", + "birthdate": "1995-05-15" + } + ] +} +``` + +--- + +## 📄 نمونه پاسخ (دوره رایگان): + +```json +{ + "message": "Transaction Participant created successfully.", + "transaction_id": 123, + "payment_method": Free, + "payment_link": null, + "participant_infos": [ + { + "fullname": "علی رضایی", + "email": "ali@example.com", + "phone_number": "+989123456789", + "gender": "male", + "birthdate": "1995-05-15" + } + ] +} +``` + +--- + +## 📄 نمونه پاسخ (دوره پولی - Payment_Gateway): + +```json +{ + "message": "Transaction Participant created successfully.", + "transaction_id": 374, + "payment_method": "Payment_Gateway", + "payment_link": "https://russia-payment.com/pay/374", + "participant_infos": [ + { + "fullname": "John Doe", + "email": "john@example.com", + "phone_number": "+1234567890", + "gender": "male", + "birthdate": "1990-01-01" + } + ] +} +``` + +--- + +## 📄 نمونه پاسخ (دوره پولی - Receipt): + +```json +{ + "message": "Transaction Participant created successfully.", + "transaction_id": 375, + "payment_method": "receipt", + "payment_link": null, + "participant_infos": [ + { + "fullname": "Иван Иванов", + "email": "ivan@example.ru", + "phone_number": "+71234567890", + "gender": "male", + "birthdate": "1992-05-15" + } + ] +} +``` + +**نکته**: برای روش پرداخت Receipt، کاربر باید رسید پرداخت خود را از طریق واتس‌اپ آپلود کند. + +--- + +## 📄 نمونه درخواست (cURL): + +```bash +curl -X POST \\ + 'http://your-api.com/api/transactions/python-programming-basics/join/' \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "participant_infos": [ + { + "fullname": "علی رضایی", + "email": "ali@example.com", + "phone_number": "+989123456789", + "gender": "male", + "birthdate": "1995-05-15" + } + ] + }' +``` +""" + + +hadis_list_swagger = swagger_auto_schema( + operation_description=""" + Retrieve a paginated list of Hadis (traditions) for a specific category. + + **Key Features:** + - Returns hadis entries filtered by category ID + - Supports pagination for large datasets + - Translations are automatically provided based on the Accept-Language header + - Each hadis includes its category information, title, narrator, Arabic text, and translation + + **Usage:** + - Use this endpoint to browse hadis within a specific category + - The response includes pagination links (next/previous) for navigation + - Set the Accept-Language header to get translations in your preferred language (en, fa, ar, ur) + - Only active (status=True) hadis are returned + + **Response Structure:** + - `count`: Total number of hadis in the category + - `next`: URL for the next page (null if on last page) + - `previous`: URL for the previous page (null if on first page) + - `results`: Array of hadis objects with full details + """, + operation_summary="List Hadis by Category", + tags=['Hadis'], + manual_parameters=[ + openapi.Parameter( + 'category_slug', + openapi.IN_PATH, + description="Unique identifier of the Hadis category. Must be a valid category ID that exists in the system.", + type=openapi.TYPE_STRING, + required=True, + example='-330' + ), + openapi.Parameter( + 'page', + openapi.IN_QUERY, + description="Page number for pagination. Starts from 1. If not provided, returns the first page.", + type=openapi.TYPE_INTEGER, + required=False, + example=1 + ), + openapi.Parameter( + 'Accept-Language', + openapi.IN_HEADER, + description="Language code for translations. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu). Defaults to 'en' if not specified.", + type=openapi.TYPE_STRING, + required=False, + default='en', + enum=['en', 'fa', 'ar', 'ur'] + ) + ], + responses={ + status.HTTP_200_OK: openapi.Response( + description="Successfully retrieved paginated list of hadis for the specified category", + examples={ + "application/json": { + "count": 150, + "next": "http://example.com/api/hadis/category/1/?page=2", + "previous": None, + "results": [ + { + "id": 1, + "number": 1, + "title": "The Opening", + "title_narrator": "From Abu Hurairah", + "text": "إنما الأعمال بالنيات وإنما لكل امرئ ما نوى", + "translation": "Actions are but by intention, and every man shall have only what he intended", + "category": { + "id": 1, + "title": "Book of Faith", + "slug": "book-of-faith", + "source_type": "hadith", + "sect_type": "sunni" + }, + "status": { + "id": 130, + "title": "Прерванный", + "color": "orange" + }, + "share_link": "http://example.com/hadis/1" + }, + { + "id": 2, + "number": 2, + "title": "The Second Hadith", + "title_narrator": "From Umar ibn al-Khattab", + "text": "بينما نحن عند رسول الله صلى الله عليه وسلم ذات يوم", + "translation": "While we were sitting with the Messenger of Allah (peace be upon him) one day", + "category": { + "id": 1, + "title": "Book of Faith", + "slug": "book-of-faith", + "source_type": "hadith", + "sect_type": "sunni" + }, + "status": { + "id": 130, + "title": "Прерванный", + "color": "orange" + }, + "share_link": "http://example.com/hadis/2" + } + ] + } + } + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="The specified category ID does not exist or the category has no active hadis", + examples={ + "application/json": { + "detail": "Not found." + } + } + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response( + description="Internal server error occurred while processing the request" + ) + } +) \ No newline at end of file diff --git a/apps/transaction/management/__init__.py b/apps/transaction/management/__init__.py new file mode 100644 index 0000000..11942f5 --- /dev/null +++ b/apps/transaction/management/__init__.py @@ -0,0 +1 @@ +# Management commands for transaction app \ No newline at end of file diff --git a/apps/transaction/management/commands/__init__.py b/apps/transaction/management/commands/__init__.py new file mode 100644 index 0000000..0b1b20d --- /dev/null +++ b/apps/transaction/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands \ No newline at end of file diff --git a/apps/transaction/management/commands/sync_successful_transactions.py b/apps/transaction/management/commands/sync_successful_transactions.py new file mode 100644 index 0000000..cddae07 --- /dev/null +++ b/apps/transaction/management/commands/sync_successful_transactions.py @@ -0,0 +1,201 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone +from apps.transaction.models import TransactionParticipant +from apps.course.models import Participant +from apps.account.models import User + + +class Command(BaseCommand): + help = 'بررسی تراکنش‌های موفق و ایجاد شرکت‌کنندگان دوره در صورت عدم وجود' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='فقط نمایش تراکنش‌هایی که نیاز به بروزرسانی دارند بدون اعمال تغییرات', + ) + parser.add_argument( + '--transaction-id', + type=int, + help='بررسی تراکنش خاص با ID مشخص', + ) + parser.add_argument( + '--user-email', + type=str, + help='بررسی تراکنش‌های کاربر خاص', + ) + parser.add_argument( + '--course-slug', + type=str, + help='بررسی تراکنش‌های دوره خاص', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + transaction_id = options.get('transaction_id') + user_email = options.get('user_email') + course_slug = options.get('course_slug') + + # ساخت queryset اولیه + queryset = TransactionParticipant.objects.filter( + status=TransactionParticipant.TransactionStatus.SUCCESS, + is_deleted=False + ).select_related('user', 'course') + + # اعمال فیلترهای اضافی + if transaction_id: + queryset = queryset.filter(id=transaction_id) + + if user_email: + try: + user = User.objects.get(email=user_email) + queryset = queryset.filter(user=user) + except User.DoesNotExist: + raise CommandError(f'کاربر با ایمیل {user_email} یافت نشد.') + + if course_slug: + queryset = queryset.filter(course__slug=course_slug) + + total_transactions = queryset.count() + + if total_transactions == 0: + self.stdout.write( + self.style.WARNING('هیچ تراکنش موفقی برای بررسی یافت نشد.') + ) + return + + self.stdout.write( + self.style.SUCCESS(f'تعداد {total_transactions} تراکنش موفق برای بررسی یافت شد.') + ) + + missing_participants = [] + existing_participants = [] + errors = [] + + # بررسی هر تراکنش + for trans in queryset: + try: + # بررسی وجود participant + participant_exists = Participant.objects.filter( + student=trans.user, + course=trans.course + ).exists() + + if not participant_exists: + missing_participants.append(trans) + self.stdout.write( + self.style.WARNING( + f'❌ تراکنش {trans.id}: کاربر {trans.user.email} در دوره "{trans.course.title}" ثبت‌نام نشده' + ) + ) + else: + existing_participants.append(trans) + self.stdout.write( + self.style.SUCCESS( + f'✅ تراکنش {trans.id}: کاربر {trans.user.email} در دوره "{trans.course.title}" قبلاً ثبت‌نام شده' + ) + ) + + except Exception as e: + errors.append((trans, str(e))) + self.stdout.write( + self.style.ERROR( + f'⚠️ خطا در بررسی تراکنش {trans.id}: {str(e)}' + ) + ) + + # نمایش خلاصه + self.stdout.write('\n' + '='*50) + self.stdout.write(f'📊 خلاصه نتایج:') + self.stdout.write(f' • کل تراکنش‌های بررسی شده: {total_transactions}') + self.stdout.write(f' • شرکت‌کنندگان موجود: {len(existing_participants)}') + self.stdout.write(f' • شرکت‌کنندگان ناموجود: {len(missing_participants)}') + self.stdout.write(f' • خطاها: {len(errors)}') + self.stdout.write('='*50 + '\n') + + if not missing_participants: + self.stdout.write( + self.style.SUCCESS('🎉 همه تراکنش‌های موفق دارای شرکت‌کننده مربوطه هستند!') + ) + return + + if dry_run: + self.stdout.write( + self.style.WARNING( + f'🔍 حالت Dry Run: {len(missing_participants)} شرکت‌کننده نیاز به ایجاد دارند.' + ) + ) + self.stdout.write( + 'برای اعمال تغییرات، دستور را بدون --dry-run اجرا کنید.' + ) + return + + # ایجاد شرکت‌کنندگان ناموجود + created_count = 0 + failed_count = 0 + + self.stdout.write( + self.style.SUCCESS(f'🚀 شروع ایجاد {len(missing_participants)} شرکت‌کننده...') + ) + + for trans in missing_participants: + try: + with transaction.atomic(): + # اضافه کردن نقش student اگر وجود نداشته باشد + if not trans.user.has_role('student'): + trans.user.add_role('student') + self.stdout.write( + f' 👤 نقش student به کاربر {trans.user.email} اضافه شد' + ) + + # ایجاد participant + participant = Participant.objects.create( + student=trans.user, + course=trans.course + ) + + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f' ✅ شرکت‌کننده ایجاد شد: {trans.user.email} در دوره "{trans.course.title}"' + ) + ) + + except Exception as e: + failed_count += 1 + self.stdout.write( + self.style.ERROR( + f' ❌ خطا در ایجاد شرکت‌کننده برای تراکنش {trans.id}: {str(e)}' + ) + ) + + # نمایش نتیجه نهایی + self.stdout.write('\n' + '='*50) + self.stdout.write('🏁 نتیجه نهایی:') + self.stdout.write(f' • شرکت‌کنندگان ایجاد شده: {created_count}') + self.stdout.write(f' • شکست‌ها: {failed_count}') + + if created_count > 0: + self.stdout.write( + self.style.SUCCESS(f'✅ {created_count} شرکت‌کننده با موفقیت ایجاد شد!') + ) + + if failed_count > 0: + self.stdout.write( + self.style.ERROR(f'❌ {failed_count} مورد با خطا مواجه شد!') + ) + + self.stdout.write('='*50) + + def get_transaction_info(self, trans): + """اطلاعات کامل تراکنش را برمی‌گرداند""" + return { + 'id': trans.id, + 'user_email': trans.user.email, + 'course_title': trans.course.title, + 'course_slug': trans.course.slug, + 'price': trans.price, + 'created_at': trans.created_at, + 'status': trans.status + } \ No newline at end of file diff --git a/apps/transaction/migrations/0001_initial.py b/apps/transaction/migrations/0001_initial.py index 4e010b3..899a98b 100644 --- a/apps/transaction/migrations/0001_initial.py +++ b/apps/transaction/migrations/0001_initial.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Generated by Django 3.2.4 on 2024-11-30 22:25 from django.conf import settings @@ -5,6 +6,15 @@ from django.db import migrations, models import django.db.models.deletion import phonenumber_field.modelfields import utils.validators +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import phonenumber_field.modelfields +import utils.validators +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -12,8 +22,13 @@ class Migration(migrations.Migration): initial = True dependencies = [ +<<<<<<< HEAD migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('course', '0005_participant_unread_messages_count'), +======= + ('course', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), +>>>>>>> develop ] operations = [ @@ -34,8 +49,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('fullname', models.CharField(help_text='Enter the full name of the user.', max_length=255, verbose_name='Full Name')), +<<<<<<< HEAD ('email', models.EmailField(help_text="Enter the user's email address.", max_length=254, unique=True, verbose_name='Email Address')), ('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')), +======= + ('email', models.EmailField(help_text="Enter the user's email address.", max_length=254, verbose_name='Email Address')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='phone')), +>>>>>>> develop ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female')], help_text="Select the user's gender.", max_length=20, null=True, verbose_name='Gender')), ('birthdate', models.DateField(blank=True, null=True, verbose_name='birthdate')), ('transaction_participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participant_infos', to='transaction.transactionparticipant', verbose_name='Transaction Participant')), diff --git a/apps/transaction/migrations/0002_remove_transactionparticipant_is_paid_and_more.py b/apps/transaction/migrations/0002_remove_transactionparticipant_is_paid_and_more.py new file mode 100644 index 0000000..cd0329e --- /dev/null +++ b/apps/transaction/migrations/0002_remove_transactionparticipant_is_paid_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.8 on 2025-04-07 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transaction', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='transactionparticipant', + name='is_paid', + ), + migrations.AddField( + model_name='transactionparticipant', + name='is_deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='transactionparticipant', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20, verbose_name='Transaction Status'), + ), + ] diff --git a/apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py b/apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py new file mode 100644 index 0000000..a0fd68c --- /dev/null +++ b/apps/transaction/migrations/0003_alter_transactionparticipant_status_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.8 on 2025-12-03 23:32 + +import apps.transaction.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transaction', '0002_remove_transactionparticipant_is_paid_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='transactionparticipant', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('waiting_approval', 'Waiting for Approval'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20, verbose_name='Transaction Status'), + ), + migrations.CreateModel( + name='TransactionReceipt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(help_text='Upload payment receipt image or document', upload_to=apps.transaction.models.receipt_file_upload_to, verbose_name='Receipt File')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Uploaded At')), + ('description', models.TextField(blank=True, help_text='Optional description or notes about the receipt', null=True, verbose_name='Description')), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', to='transaction.transactionparticipant', verbose_name='Transaction')), + ], + options={ + 'verbose_name': 'Transaction Receipt', + 'verbose_name_plural': 'Transaction Receipts', + 'ordering': ['-uploaded_at'], + }, + ), + ] diff --git a/apps/transaction/migrations/0004_transactionparticipant_payment_method.py b/apps/transaction/migrations/0004_transactionparticipant_payment_method.py new file mode 100644 index 0000000..0785a7c --- /dev/null +++ b/apps/transaction/migrations/0004_transactionparticipant_payment_method.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2025-12-28 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("transaction", "0003_alter_transactionparticipant_status_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="transactionparticipant", + name="payment_method", + field=models.CharField( + choices=[ + ("receipt", "Receipt"), + ("Payment_Gateway", "Payment Gateway"), + ], + default="Payment_Gateway", + max_length=20, + verbose_name="Transaction Payment Method", + ), + ), + ] diff --git a/apps/transaction/migrations/0005_alter_transactionparticipant_payment_method.py b/apps/transaction/migrations/0005_alter_transactionparticipant_payment_method.py new file mode 100644 index 0000000..3b1090e --- /dev/null +++ b/apps/transaction/migrations/0005_alter_transactionparticipant_payment_method.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.27 on 2025-12-28 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("transaction", "0004_transactionparticipant_payment_method"), + ] + + operations = [ + migrations.AlterField( + model_name="transactionparticipant", + name="payment_method", + field=models.CharField( + choices=[ + ("receipt", "Receipt"), + ("free", "Free"), + ("Payment_Gateway", "Payment Gateway"), + ], + default="Payment_Gateway", + max_length=20, + verbose_name="Transaction Payment Method", + ), + ), + ] diff --git a/apps/transaction/models.py b/apps/transaction/models.py index 958ba77..b852d90 100644 --- a/apps/transaction/models.py +++ b/apps/transaction/models.py @@ -1,4 +1,5 @@ from django.db import models +import os from django.utils.translation import gettext_lazy as _ @@ -8,19 +9,57 @@ from phonenumber_field.modelfields import PhoneNumberField from utils.validators import validate_possible_number +def receipt_file_upload_to(instance, filename): + return os.path.join(f"receipts/{instance.transaction.id}/{filename}") + + class TransactionParticipant(models.Model): + + + class TransactionStatus(models.TextChoices): + PENDING = 'pending', _('Pending') + WAITING_APPROVAL = 'waiting_approval', _('Waiting for Approval') + SUCCESS = 'success', _('Success') + FAILED = 'failed', _('Failed') + + class PaymentMethods(models.TextChoices): + RECEIPT = 'receipt', _('Receipt') + FREE = 'free', _('Free') + PAYMENT_GATEWAY = 'Payment_Gateway', _('Payment Gateway') + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='transactions') - course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_transactions') - is_paid = models.BooleanField(default=False, verbose_name='Payment Status', help_text='Indicates whether the payment has been completed or not') + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_transactions') + payment_method=models.CharField(max_length=20, choices=PaymentMethods.choices, default=PaymentMethods.PAYMENT_GATEWAY, verbose_name=_('Transaction Payment Method')) + # is_paid = models.BooleanField(default=False, verbose_name='Payment Status', help_text='Indicates whether the payment has been completed or not') price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='Transaction Price') - + status = models.CharField(max_length=20, choices=TransactionStatus.choices, default=TransactionStatus.PENDING, verbose_name=_('Transaction Status')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) + is_deleted = models.BooleanField(default=False) - + def __str__(self): + return f"{self.user.email} - {self.course.title} ({self.status})" + + def is_participant_enrolled(self): + """بررسی اینکه آیا کاربر در دوره ثبت‌نام شده یا نه""" + from apps.course.models import Participant + return Participant.objects.filter( + student=self.user, + course=self.course + ).exists() + + def get_participant(self): + """دریافت شرکت‌کننده دوره اگر وجود داشته باشد""" + from apps.course.models import Participant + return Participant.objects.filter( + student=self.user, + course=self.course + ).first() + + class ParticipantInfo(models.Model): @@ -29,9 +68,9 @@ class ParticipantInfo(models.Model): FEMALE = 'female', 'Female' transaction_participant = models.ForeignKey( - TransactionParticipant, - on_delete=models.CASCADE, - related_name='participant_infos', + TransactionParticipant, + on_delete=models.CASCADE, + related_name='participant_infos', verbose_name="Transaction Participant" ) fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.") @@ -42,6 +81,41 @@ class ParticipantInfo(models.Model): ) birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) + def __str__(self): + return f"{self.fullname} (Transaction: {self.transaction_participant.id}) - {self.email}" + + +class TransactionReceipt(models.Model): + """ + Model for storing payment receipts uploaded by users for transactions + """ + transaction = models.ForeignKey( + TransactionParticipant, + on_delete=models.CASCADE, + related_name='receipts', + verbose_name=_('Transaction') + ) + file = models.FileField( + upload_to=receipt_file_upload_to, + verbose_name=_('Receipt File'), + help_text=_('Upload payment receipt image or document') + ) + uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Uploaded At')) + description = models.TextField( + blank=True, + null=True, + verbose_name=_('Description'), + help_text=_('Optional description or notes about the receipt') + ) + + class Meta: + verbose_name = _('Transaction Receipt') + verbose_name_plural = _('Transaction Receipts') + ordering = ['-uploaded_at'] + + def __str__(self): + return f"Receipt for Transaction #{self.transaction.id} - {self.uploaded_at.strftime('%Y-%m-%d %H:%M')}" + diff --git a/apps/transaction/serializers.py b/apps/transaction/serializers.py index 01b6c0a..f3c91f7 100644 --- a/apps/transaction/serializers.py +++ b/apps/transaction/serializers.py @@ -1,8 +1,9 @@ from rest_framework import serializers -from apps.transaction.models import TransactionParticipant, ParticipantInfo +from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt from apps.course.serializers import CourseDetailSerializer +from utils import FileFieldSerializer @@ -39,11 +40,57 @@ class TransactionParticipantSerializer(serializers.ModelSerializer): class TransactionListSerializer(serializers.ModelSerializer): course = serializers.SerializerMethodField() - + receipts = serializers.SerializerMethodField() + class Meta: model = TransactionParticipant - fields = ['course', 'is_paid', 'price', 'created_at', 'updated_at'] - + fields = ['id', 'course', 'status', 'price', 'receipts', 'created_at', 'updated_at'] + def get_course(self, obj): return CourseDetailSerializer(obj.course, context=self.context).data - \ No newline at end of file + + def get_receipts(self, obj): + receipts = obj.receipts.all() + return TransactionReceiptSerializer(receipts, many=True, context=self.context).data + + +class TransactionReceiptSerializer(serializers.ModelSerializer): + """ + Serializer for uploading payment receipts + Uses FileFieldSerializer to handle pre-uploaded files from /upload-tmp-media/ + """ + file = FileFieldSerializer() + + class Meta: + model = TransactionReceipt + fields = ['id', 'file', 'description', 'uploaded_at'] + read_only_fields = ['id', 'uploaded_at'] + + +class UploadReceiptsSerializer(serializers.Serializer): + """ + Serializer for uploading multiple receipt files for a transaction. + Files should be pre-uploaded using /upload-tmp-media/ endpoint, + then their URLs should be sent here. + """ + files = serializers.ListField( + child=FileFieldSerializer(), + allow_empty=False, + max_length=10, + help_text="List of file URLs (max 10 files) - files should be pre-uploaded via /upload-tmp-media/" + ) + description = serializers.CharField( + required=False, + allow_blank=True, + max_length=1000, + help_text="Optional description for the receipts" + ) + + def validate_files(self, files): + """ + Validate uploaded file URLs + """ + if len(files) > 10: + raise serializers.ValidationError("You can upload a maximum of 10 files.") + + return files \ No newline at end of file diff --git a/apps/transaction/signals.py b/apps/transaction/signals.py new file mode 100644 index 0000000..cf870f9 --- /dev/null +++ b/apps/transaction/signals.py @@ -0,0 +1,67 @@ +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver +from django.db import transaction + +from apps.transaction.models import TransactionParticipant +from apps.course.models import Participant + + +@receiver(pre_save, sender=TransactionParticipant) +def store_previous_status(sender, instance, **kwargs): + """ + Store the previous status before saving to compare with new status + """ + if instance.pk: + try: + previous_instance = TransactionParticipant.objects.get(pk=instance.pk) + instance._previous_status = previous_instance.status + except TransactionParticipant.DoesNotExist: + instance._previous_status = None + else: + instance._previous_status = None + + +@receiver(post_save, sender=TransactionParticipant) +def create_participant_on_success(sender, instance, created, **kwargs): + """ + Create course participant when transaction status changes to SUCCESS + """ + # اگر تراکنش جدید ایجاد شده و وضعیت آن SUCCESS است + if created and instance.status == TransactionParticipant.TransactionStatus.SUCCESS: + create_course_participant(instance) + + # اگر تراکنش موجود بوده و وضعیت آن از حالت دیگری به SUCCESS تغییر کرده + elif not created and hasattr(instance, '_previous_status'): + if (instance._previous_status != TransactionParticipant.TransactionStatus.SUCCESS and + instance.status == TransactionParticipant.TransactionStatus.SUCCESS): + create_course_participant(instance) + + +def create_course_participant(transaction_instance): + """ + Create course participant for successful transaction + """ + try: + with transaction.atomic(): + # بررسی اینکه آیا کاربر نقش student دارد یا نه + if not transaction_instance.user.has_role('student'): + transaction_instance.user.add_role('student') + + # بررسی اینکه آیا قبلاً participant ایجاد شده یا نه + existing_participant = Participant.objects.filter( + student=transaction_instance.user, + course=transaction_instance.course + ).first() + + if not existing_participant: + # ایجاد participant جدید + participant = Participant.objects.create( + student=transaction_instance.user, + course=transaction_instance.course + ) + print(f"Course participant created: {participant}") + else: + print(f"Course participant already exists: {existing_participant}") + + except Exception as e: + print(f"Error creating course participant: {e}") \ No newline at end of file diff --git a/apps/transaction/tests.py b/apps/transaction/tests.py index 7ce503c..08f1014 100644 --- a/apps/transaction/tests.py +++ b/apps/transaction/tests.py @@ -1,3 +1,137 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from apps.account.models import StudentUser +from apps.course.models import Course, CourseCategory, Participant +from apps.transaction.models import TransactionParticipant +from apps.account.models import ProfessorUser -# Create your tests here. +User = get_user_model() + + +class TransactionParticipantSignalTest(TestCase): + def setUp(self): + """تنظیمات اولیه برای تست""" + # ایجاد کاربر + self.user = User.objects.create_user( + email='test@example.com', + password='testpass123' + ) + + # ایجاد استاد + self.professor = ProfessorUser.objects.create( + email='professor@example.com', + password='testpass123' + ) + + # ایجاد دسته‌بندی دوره + self.category = CourseCategory.objects.create( + name='Test Category', + slug='test-category' + ) + + # ایجاد دوره + self.course = Course.objects.create( + title='Test Course', + slug='test-course', + category=self.category, + professor=self.professor, + video_type='youtube_link', + level='beginner', + duration=10, + lessons_count=5, + description='Test course description', + is_free=False, + price=100.00, + final_price=100.00 + ) + + def test_participant_created_on_success_status(self): + """تست ایجاد participant هنگام تغییر وضعیت به SUCCESS""" + # ایجاد تراکنش با وضعیت PENDING + transaction = TransactionParticipant.objects.create( + user=self.user, + course=self.course, + price=100.00, + status=TransactionParticipant.TransactionStatus.PENDING + ) + + # بررسی که participant ایجاد نشده + self.assertFalse( + Participant.objects.filter(student=self.user, course=self.course).exists() + ) + + # تغییر وضعیت به SUCCESS + transaction.status = TransactionParticipant.TransactionStatus.SUCCESS + transaction.save() + + # بررسی که participant ایجاد شده + self.assertTrue( + Participant.objects.filter(student=self.user, course=self.course).exists() + ) + + # بررسی که کاربر نقش student دارد + self.assertTrue(self.user.has_role('student')) + + def test_participant_created_on_direct_success(self): + """تست ایجاد participant هنگام ایجاد تراکنش با وضعیت SUCCESS""" + # ایجاد تراکنش مستقیماً با وضعیت SUCCESS + transaction = TransactionParticipant.objects.create( + user=self.user, + course=self.course, + price=100.00, + status=TransactionParticipant.TransactionStatus.SUCCESS + ) + + # بررسی که participant ایجاد شده + self.assertTrue( + Participant.objects.filter(student=self.user, course=self.course).exists() + ) + + # بررسی که کاربر نقش student دارد + self.assertTrue(self.user.has_role('student')) + + def test_no_duplicate_participant(self): + """تست عدم ایجاد participant تکراری""" + # ایجاد participant دستی + existing_participant = Participant.objects.create( + student=self.user, + course=self.course + ) + + # ایجاد تراکنش با وضعیت SUCCESS + transaction = TransactionParticipant.objects.create( + user=self.user, + course=self.course, + price=100.00, + status=TransactionParticipant.TransactionStatus.SUCCESS + ) + + # بررسی که فقط یک participant وجود دارد + self.assertEqual( + Participant.objects.filter(student=self.user, course=self.course).count(), + 1 + ) + + def test_model_helper_methods(self): + """تست متدهای کمکی مدل""" + # ایجاد تراکنش + transaction = TransactionParticipant.objects.create( + user=self.user, + course=self.course, + price=100.00, + status=TransactionParticipant.TransactionStatus.PENDING + ) + + # بررسی که participant وجود ندارد + self.assertFalse(transaction.is_participant_enrolled()) + self.assertIsNone(transaction.get_participant()) + + # ایجاد participant + participant = Participant.objects.create( + student=self.user, + course=self.course + ) + + # بررسی که participant وجود دارد + self.assertTrue(transaction.is_participant_enrolled()) + self.assertEqual(transaction.get_participant(), participant) diff --git a/apps/transaction/urls.py b/apps/transaction/urls.py index 01f8804..7614de9 100644 --- a/apps/transaction/urls.py +++ b/apps/transaction/urls.py @@ -1,13 +1,16 @@ -from django.urls import path +from django.urls import path, re_path from . import views urlpatterns = [ - path('/join/', views.TransactionParticipantCreateView.as_view(), name='transaction-participant-create'), + re_path(r'(?P[\w-]+)/join/$', views.TransactionParticipantCreateView.as_view(), name='transaction-participant-create'), path('list/', views.TransactiontListView.as_view(), name='transaction-list'), - + path('/delete/', views.SoftDeleteTransactionParticipantView.as_view(), name='soft-delete-transaction-participant'), + path('/receipts/upload/', views.UploadTransactionReceiptsView.as_view(), name='upload-transaction-receipts'), + path('/receipts/', views.TransactionReceiptsListView.as_view(), name='transaction-receipts-list'), ] + \ No newline at end of file diff --git a/apps/transaction/views.py b/apps/transaction/views.py index 4801f09..e8df130 100644 --- a/apps/transaction/views.py +++ b/apps/transaction/views.py @@ -1,12 +1,28 @@ from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - +from rest_framework.views import APIView from apps.course.models import Participant, Course -from apps.transaction.models import TransactionParticipant -from apps.transaction.serializers import TransactionParticipantSerializer, TransactionListSerializer +from apps.transaction.models import TransactionParticipant, TransactionReceipt +from apps.transaction.serializers import ( + TransactionParticipantSerializer, + TransactionListSerializer, + UploadReceiptsSerializer, + TransactionReceiptSerializer +) from utils.exceptions import AppAPIException from apps.account.models import User +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from apps.transaction.models import TransactionParticipant +from apps.transaction.doc import ( + doc_upload_transaction_receipts, + doc_list_transaction_receipts, + doc_transaction_list, + doc_create_transaction +) + +from utils.ip_helper import get_client_ip, get_country_code @@ -15,47 +31,170 @@ class TransactionParticipantCreateView(generics.CreateAPIView): serializer_class = TransactionParticipantSerializer permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_description=doc_create_transaction(), + responses={ + status.HTTP_201_CREATED: openapi.Response( + description="Transaction participant created successfully", + examples={ + "application/json": { + "message": "Transaction Participant created successfully.", + "transaction_id": 374, + "payment_method": "Payment_Gateway", + "payment_link": "https://russia-payment.com/pay/374", + "participant_infos": [ + { + "fullname": "string", + "email": "admin@gmail.com", + "phone_number": "string", + "gender": "male", + "birthdate": "2025-12-28" + } + ] + } + }, + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING, description="Success message"), + 'transaction_id': openapi.Schema(type=openapi.TYPE_INTEGER, description="Unique transaction identifier"), + 'payment_method': openapi.Schema( + type=openapi.TYPE_STRING, + enum=['Payment_Gateway', 'receipt'], + description="Payment method: 'Payment_Gateway' for online payment, 'receipt' for WhatsApp upload" + ), + 'payment_link': openapi.Schema( + type=openapi.TYPE_STRING, + nullable=True, + description="Payment gateway URL (only present when payment_method is 'Payment_Gateway')" + ), + 'participant_infos': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'fullname': openapi.Schema(type=openapi.TYPE_STRING), + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'phone_number': openapi.Schema(type=openapi.TYPE_STRING), + 'gender': openapi.Schema(type=openapi.TYPE_STRING, enum=['male', 'female']), + 'birthdate': openapi.Schema(type=openapi.TYPE_STRING, format='date'), + } + ), + description="List of participant information" + ), + }, + required=['message', 'transaction_id', 'participant_infos'] + ) + ), + status.HTTP_400_BAD_REQUEST: openapi.Response( + description="Invalid data provided", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'detail': openapi.Schema(type=openapi.TYPE_STRING), + } + ) + ), + status.HTTP_404_NOT_FOUND: openapi.Response( + description="Course not found", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING), + } + ) + ), + }, + tags=['Imam-Javad - Transaction'] + ) + def post(self, request, *args, **kwargs): + # Simply call the create method + return self.create(request, *args, **kwargs) def create(self, request, *args, **kwargs): user = request.user - course_slug = self.kwargs.get('slug') # Get the slug from the URL + course_slug = self.kwargs.get('slug') + + # 1. Retrieve Course try: - course = Course.objects.get(slug=course_slug) # Retrieve the Course object + course = Course.objects.get(slug=course_slug) except Course.DoesNotExist: - raise AppAPIException({'message': "Course not found"}) # Handle course not found + raise AppAPIException({'message': "Course not found"}) participant_infos = request.data.get('participant_infos', []) - print(f'1---> {participant_infos}') - print(f'2---> {len(participant_infos)}') + # 2. Validate and Initialize serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - + statis = TransactionParticipant.TransactionStatus.PENDING + + # 3. Handle Free/Self-Enrollment Logic if len(participant_infos) == 1 and (course.final_price == 0 or course.is_free): participant = participant_infos[0] if participant.get('email') != user.email: raise AppAPIException({'message': "The email must be for the requesting user"}) - - if user.user_type != User.UserType.STUDENT: - user = User.objects.change_user_type(user, User.UserType.STUDENT) - participant, created = Participant.objects.get_or_create( - student=user, - course=course - ) - return Response({ - 'message': 'Transaction Participant created successfully.', - 'participant_id': participant.id, - 'participant_infos': serializer.data['participant_infos'] - }, status=status.HTTP_201_CREATED) + if not user.has_role('student'): + user.add_role('student') - - - transaction_participant = serializer.save(user=user, course=course, price=course.final_price) + existing_participant = Participant.objects.filter(student=user, course=course).first() + if existing_participant: + participant = existing_participant + else: + participant = Participant.objects.create(student=user, course=course) + statis = TransactionParticipant.TransactionStatus.SUCCESS + + # 4. Save Transaction + transaction_participant = serializer.save( + user=user, + course=course, + price=course.final_price, + status=statis + ) print(f'---> {type(transaction_participant)}/ {transaction_participant}') + + # ======================================================= + # NEW LOGIC: HYBRID GEOLOCATION CHECK (Cloudflare + Local DB) + # ======================================================= + + payment_link = None + + payment_method = TransactionParticipant.PaymentMethods.FREE + if statis == TransactionParticipant.TransactionStatus.PENDING: + + # Step A: Fast Path - Check Cloudflare Header + # Cloudflare sends the 2-letter code (e.g., 'RU', 'US') in this header + country_code = request.META.get('HTTP_CF_IPCOUNTRY') + + # Step B: Slow Path - Fallback to Local DB + # If header is missing (e.g., Localhost, direct connection, or CF failed) + if not country_code: + try: + client_ip =get_client_ip(request) + # "188.93.104.1" + # get_client_ip(request) + # Assuming your helper handles errors gracefully and returns None + country_code = get_country_code(client_ip) + except Exception as e: + print(f"GeoIP Lookup Failed: {e}") + country_code = None + payment_method = TransactionParticipant.PaymentMethods.RECEIPT + # Step C: Apply Logic + if country_code != 'RU': + payment_method = TransactionParticipant.PaymentMethods.PAYMENT_GATEWAY + payment_link = f"https://russia-payment.com/pay/{transaction_participant.id}" + + # Uncomment if you want a global fallback link + # else: + # payment_link = f"https://global-payment.com/pay/{transaction_participant.id}" + + # ======================================================= + return Response({ 'message': 'Transaction Participant created successfully.', 'transaction_id': transaction_participant.id, + 'payment_method':payment_method, + 'payment_link': payment_link, 'participant_infos': serializer.data['participant_infos'] }, status=status.HTTP_201_CREATED) @@ -66,12 +205,199 @@ class TransactionParticipantCreateView(generics.CreateAPIView): class TransactiontListView(generics.ListAPIView): - queryset = TransactionParticipant.objects.all() # یا هر فیلتر که بخواهید اضافه کنید + queryset = TransactionParticipant.objects.all() serializer_class = TransactionListSerializer - permission_classes = [IsAuthenticated] # برای دسترسی کاربران احراز هویت شده - + permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_description=doc_transaction_list(), + tags=['Imam-Javad - Transaction'] + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) def get_queryset(self): queryset = super().get_queryset() - queryset = queryset.filter(user=self.request.user) - return queryset \ No newline at end of file + queryset = queryset.filter(user=self.request.user, is_deleted=False) + return queryset + + + +class SoftDeleteTransactionParticipantView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="Soft delete a transaction participant", + operation_description="Marks a transaction participant as deleted without removing it from the database", + tags=['Imam-Javad - Transaction'], + manual_parameters=[ + openapi.Parameter( + 'id', + openapi.IN_PATH, + description="Transaction Participant ID", + type=openapi.TYPE_INTEGER, + required=True + ) + ], + responses={ + 200: openapi.Response( + description="Transaction participant successfully marked as deleted", + examples={ + "application/json": { + "success": True, + "message": "Transaction participant successfully marked as deleted" + } + } + ), + 404: "Transaction participant not found", + 403: "Permission denied" + } + ) + def delete(self, request, pk): + try: + transaction = TransactionParticipant.objects.get(pk=pk) + if transaction.user == request.user: + transaction.is_deleted = True + transaction.save() + return Response({ + "success": True, + "message": "Transaction participant successfully marked as deleted" + }, status=status.HTTP_200_OK) + else: + raise AppAPIException( + detail={'message': "You don't have permission to delete this transaction"}, + status_code=status.HTTP_403_FORBIDDEN + ) + + except TransactionParticipant.DoesNotExist: + raise AppAPIException({'message': "Transaction participant not found"}) + + +class UploadTransactionReceiptsView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="Upload payment receipts for a transaction", + operation_description=doc_upload_transaction_receipts(), + tags=['Imam-Javad - Transaction'], + request_body=UploadReceiptsSerializer, + responses={ + 201: openapi.Response( + description="Receipts uploaded successfully", + examples={ + "application/json": { + "success": True, + "message": "Receipts uploaded successfully", + "transaction_status": "waiting_approval", + "receipts": [ + { + "id": 1, + "file": "http://example.com/media/receipts/1/receipt.jpg", + "description": "Payment receipt", + "uploaded_at": "2025-12-03T10:30:00Z" + } + ] + } + } + ), + 400: "Invalid data or transaction cannot accept receipts", + 403: "Permission denied", + 404: "Transaction not found" + } + ) + def post(self, request, transaction_id): + try: + transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False) + except TransactionParticipant.DoesNotExist: + raise AppAPIException({'message': "Transaction not found"}) + + # Check if user owns this transaction + if transaction.user != request.user: + raise AppAPIException( + detail={'message': "You don't have permission to upload receipts for this transaction"}, + status_code=status.HTTP_403_FORBIDDEN + ) + + # Check if transaction is in a state that can accept receipts + if transaction.status not in [ + TransactionParticipant.TransactionStatus.PENDING, + TransactionParticipant.TransactionStatus.WAITING_APPROVAL + ]: + raise AppAPIException( + detail={'message': f"Cannot upload receipts for transaction with status '{transaction.status}'"}, + status_code=status.HTTP_400_BAD_REQUEST + ) + + # Validate using serializer + serializer = UploadReceiptsSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # Create receipt records + receipts = [] + description = serializer.validated_data.get('description', '') + file_urls = serializer.validated_data.get('files', []) + + for file_url in file_urls: + receipt = TransactionReceipt.objects.create( + transaction=transaction, + file=file_url, + description=description + ) + receipts.append(receipt) + + # Update transaction status to waiting_approval + transaction.status = TransactionParticipant.TransactionStatus.WAITING_APPROVAL + transaction.save() + + # Serialize receipts for response + receipts_data = TransactionReceiptSerializer(receipts, many=True, context={'request': request}).data + + return Response({ + 'success': True, + 'message': 'Receipts uploaded successfully', + 'transaction_status': transaction.status, + 'receipts': receipts_data + }, status=status.HTTP_201_CREATED) + + +class TransactionReceiptsListView(generics.ListAPIView): + serializer_class = TransactionReceiptSerializer + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="List receipts for a transaction", + operation_description=doc_list_transaction_receipts(), + tags=['Imam-Javad - Transaction'], + manual_parameters=[ + openapi.Parameter( + 'transaction_id', + openapi.IN_PATH, + description="Transaction ID", + type=openapi.TYPE_INTEGER, + required=True + ) + ], + responses={ + 200: TransactionReceiptSerializer(many=True), + 403: "Permission denied", + 404: "Transaction not found" + } + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def get_queryset(self): + transaction_id = self.kwargs.get('transaction_id') + + try: + transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False) + except TransactionParticipant.DoesNotExist: + raise AppAPIException({'message': "Transaction not found"}) + + # Check if user owns this transaction + if transaction.user != self.request.user: + raise AppAPIException( + detail={'message': "You don't have permission to view receipts for this transaction"}, + status_code=status.HTTP_403_FORBIDDEN + ) + + return TransactionReceipt.objects.filter(transaction=transaction) diff --git a/apps/video/admin.py b/apps/video/admin.py index 5c23d54..518505a 100644 --- a/apps/video/admin.py +++ b/apps/video/admin.py @@ -2,51 +2,122 @@ 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 unfold.decorators import display, action +from django import forms + +from utils.admin import dovoodi_admin_site +from unfold.sections import TableSection from apps.video.models import * -class VideoInCollectionInline(admin.TabularInline): - model = VideoInCollection +class VideoPlaylistInCollectionInlineForCollection(TabularInline): + model = VideoPlaylistInCollection extra = 1 - autocomplete_fields = ('video',) - ordering = ('priority',) - + autocomplete_fields = ('playlist',) + fields = ('playlist', 'order') + ordering = ('order',) + verbose_name = _('Playlist') + verbose_name_plural = _('Playlists') + tab = True -class VideoCollectionAdminBase(AjaxDatatable): - """Base admin class for all video collection types""" - list_display = ('title', 'status', 'order', 'count_videos', 'created_at') - list_filter = ('status', 'created_at', 'updated_at') +class VideoCollectionAdminBase(ModelAdmin): + list_display = ('get_title', 'status', 'order', 'count_playlists') + list_filter = ('status', 'order') search_fields = ('title',) - inlines = [VideoInCollectionInline] + ordering = ('order',) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + inlines = [VideoPlaylistInCollectionInlineForCollection] + fieldsets = ( (None, { - 'fields': ('title', 'status', 'order') + 'fields': ('title', 'summary', 'thumbnail' , 'status', 'pin_top', 'order') }), ) + exclude = ('display_position',) - @admin.display(description=_('Number of Videos')) - def count_videos(self, obj): - count = obj.videos.count() + @display(description=_('Title')) + def get_title(self, obj): + return str(obj.title) + + @display(description=_('Number of Playlists')) + def count_playlists(self, obj): + count = obj.related_playlists.count() if count > 0: - url = reverse('admin:video_video_changelist') + f'?collections__id__exact={obj.id}' + url = reverse('admin:video_videoplaylist_changelist') + f'?collections__id__exact={obj.id}' return format_html('{}', url, count) return count -# @admin.register(VideoCollection) -# class VideoCollectionAdmin(VideoCollectionAdminBase): -# """Admin for all video collections""" -# list_display = ('title', 'status', 'count_videos', 'created_at') -# list_filter = ('status', 'created_at', 'updated_at') +class PinnedVideoCollectionForm(forms.ModelForm): + class Meta: + model = PinnedVideoCollection + # fields = '__all__' + exclude = ('slug',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['thumbnail'].required = True + +class PinnedVideoCollectionAdmin(VideoCollectionAdminBase): + form = PinnedVideoCollectionForm + + def get_queryset(self, request): + return super().get_queryset(request).filter(display_position=VideoCollection.DisplayPosition.PINNED) + + def save_model(self, request, obj, form, change): + obj.display_position = VideoCollection.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 + # return [ + # obj.title, + # None, + # None, + # { + # "path": thumbnail_path, + # "height": 30, + # "width": 50, + # "borderless": True, + # # "squared": True, + # }, + # ] + +class MiddleVideoCollectionAdmin(VideoCollectionAdminBase): + + + fieldsets = ( + (None, { + 'fields': ('title', 'status', 'pin_top', 'order') + }), + ) + + def get_queryset(self, request): + return super().get_queryset(request).filter(display_position=VideoCollection.DisplayPosition.MIDDLE) + + def save_model(self, request, obj, form, change): + obj.display_position = VideoCollection.DisplayPosition.MIDDLE + super().save_model(request, obj, form, change) -@admin.register(VideoCategory) -class VideoCategoryAdmin(AjaxDatatable): + + +class VideoCategoryAdmin(ModelAdmin): list_display = ('title', 'slug', 'status', 'order', 'count_videos', 'created_at') list_filter = ('status', 'created_at', 'updated_at') search_fields = ('title', 'slug') @@ -54,23 +125,43 @@ class VideoCategoryAdmin(AjaxDatatable): @admin.display(description=_('Number of Videos')) def count_videos(self, obj): - count = obj.videos.count() + # Count videos through playlists: Category -> Playlist -> PlaylistItem -> Video + count = Video.objects.filter( + playlist_appearances__playlist__categories=obj + ).distinct().count() + if count > 0: - url = reverse('admin:video_video_changelist') + f'?category__id__exact={obj.id}' - return format_html('{}', url, count) + # Note: Direct filtering by category in admin might not work due to the relationship + # We'll just display the count without a clickable link for now + return 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 + -@admin.register(Video) -class VideoAdmin(AjaxDatatable): +class VideoAdmin(ModelAdmin): list_display = ('title', 'slug', 'video_type', 'status', 'view_count', 'created_at') list_filter = ('status', 'video_type', 'created_at', 'updated_at') search_fields = ('title', 'slug', 'description') - autocomplete_fields = ('categories',) + conditional_fields = { + 'video_file': "video_type == 'video_file'", + 'video_url': "video_type == 'youtube_link'", + } + radio_fields = { + "video_type": admin.HORIZONTAL, + } + save_as = True + search_help_text = _("Search by title, slug, or description") + search_fields_placeholder = _("Search videos") fieldsets = ( (None, { - 'fields': ('title', 'slug', 'description', 'thumbnail', 'categories') + 'fields': ('title', 'slug', 'description', 'thumbnail') }), (_('Video Information'), { 'fields': ('video_type', 'video_file', 'video_url', 'video_time') @@ -82,4 +173,171 @@ class VideoAdmin(AjaxDatatable): '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 + + if form.base_fields.get('video_type') and not obj: + form.base_fields['video_type'].initial = 'youtube_link' + return form + + +class PlaylistItemForm(forms.ModelForm): + class Meta: + model = PlaylistItem + fields = ('video', 'priority') + + def clean_video(self): + video = self.cleaned_data.get('video') + if not video: + return video + + # If we're editing, exclude the current instance from the check + instance = getattr(self, 'instance', None) + if instance and instance.pk and instance.video == video: + return video + + # Check if this video exists in another playlist + existing_item = PlaylistItem.objects.filter(video=video).first() + if existing_item: + playlist_name = existing_item.playlist.title + raise forms.ValidationError( + _('This video is already used in playlist "{}". Each video can only be in one playlist.').format(playlist_name) + ) + return video + + +class PlaylistItemInline(StackedInline): + model = PlaylistItem + form = PlaylistItemForm + extra = 1 + autocomplete_fields = ('video',) + fields = ('video', 'priority') + ordering = ('priority',) + verbose_name = _('Playlist Item') + verbose_name_plural = _('Playlist Items') + + +class VideoPlaylistInCollectionInline(TabularInline): + model = VideoPlaylistInCollection + extra = 1 + raw_id_fields = ('collection',) + fields = ('collection', 'order') + ordering = ('order',) + verbose_name = _('Collection') + verbose_name_plural = _('Collections') + tab = True + + +class VideoPlaylistAdmin(ModelAdmin): + list_display = ('title', 'slug', 'status', 'order', 'view_count', 'count_videos', 'created_at') + list_filter = ('status', 'created_at', 'categories') + search_fields = ('title', 'slug', 'slogan', 'description') + autocomplete_fields = ('categories',) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + inlines = [PlaylistItemInline, VideoPlaylistInCollectionInline] + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'slogan', 'description', 'thumbnail') + }), + (_('Categories'), { + 'fields': ('categories',) + }), + (_('Display Settings'), { + 'fields': ('order', 'status') + }), + (_('Statistics'), { + 'fields': ('view_count', 'total_time') + }), + ) + + 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 = False + if form.base_fields.get('total_time'): + form.base_fields['total_time'].required = False + form.base_fields['total_time'].help_text = _('Will be auto-calculated from videos') + return form + + @display(description=_('Number of Videos')) + def count_videos(self, obj): + count = obj.playlist_items.count() + if count > 0: + return format_html('{}', count) + return count + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + # Auto-calculate total_time + obj.total_time = obj.calculate_total_time() + obj.save(update_fields=['total_time']) + + def save_formset(self, request, form, formset, change): + """ + Additional validation to ensure each video is used in only one playlist + """ + instances = formset.save(commit=False) + + # Collect all videos that are being saved + videos_to_save = [] + for instance in instances: + if instance.video: + videos_to_save.append(instance.video) + + # Check for duplicate videos in this formset + video_counts = {} + for video in videos_to_save: + video_counts[video.id] = video_counts.get(video.id, 0) + 1 + + duplicate_videos = [video_id for video_id, count in video_counts.items() if count > 1] + if duplicate_videos: + # If there are duplicate videos in this form, show an error + formset._non_form_errors = formset.error_class( + [_('A video cannot be used multiple times in the same playlist.')] + ) + return + + # Check if videos are used in other playlists + for instance in instances: + if instance.video: # For both new and edited items + playlist_id = form.instance.pk + query = PlaylistItem.objects.filter( + video=instance.video + ).exclude( + playlist_id=playlist_id + ) + + # If we're editing an existing item, exclude it from the check + if instance.pk: + query = query.exclude(pk=instance.pk) + + existing_item = query.first() + + if existing_item: + playlist_name = existing_item.playlist.title + formset._non_form_errors = formset.error_class( + [_('Video "{}" is already used in playlist "{}". Each video can only be in one playlist.').format( + instance.video.title, playlist_name + )] + ) + return + + # If all validations pass, save the formset + super().save_formset(request, form, formset, change) + +dovoodi_admin_site.register(VideoCategory, VideoCategoryAdmin) +dovoodi_admin_site.register(Video, VideoAdmin) +dovoodi_admin_site.register(PinnedVideoCollection, PinnedVideoCollectionAdmin) +dovoodi_admin_site.register(MiddleVideoCollection, MiddleVideoCollectionAdmin) +dovoodi_admin_site.register(VideoPlaylist, VideoPlaylistAdmin) diff --git a/apps/video/management/__init__.py b/apps/video/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/video/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/video/management/commands/__init__.py b/apps/video/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/video/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/video/management/commands/cleanup_video_data.py b/apps/video/management/commands/cleanup_video_data.py new file mode 100644 index 0000000..1894de8 --- /dev/null +++ b/apps/video/management/commands/cleanup_video_data.py @@ -0,0 +1,61 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.video.models import VideoCategory, VideoCollection, VideoPlaylist, PlaylistItem + + +class Command(BaseCommand): + help = 'Delete all data from VideoCategory, VideoCollection, and VideoPlaylist (keeps Video model data)' + + def add_arguments(self, parser): + parser.add_argument( + '--confirm', + action='store_true', + help='Confirm deletion without prompting' + ) + + def handle(self, *args, **options): + confirm = options.get('confirm', False) + + # Count current data + category_count = VideoCategory.objects.count() + collection_count = VideoCollection.objects.count() + playlist_count = VideoPlaylist.objects.count() + playlist_item_count = PlaylistItem.objects.count() + + self.stdout.write(self.style.WARNING('\n=== Current Data Count ===')) + self.stdout.write(f'VideoCategory: {category_count}') + self.stdout.write(f'VideoCollection: {collection_count}') + self.stdout.write(f'VideoPlaylist: {playlist_count}') + self.stdout.write(f'PlaylistItem: {playlist_item_count}') + self.stdout.write(self.style.WARNING('\n=== Video Data Will NOT Be Deleted ===\n')) + + if not confirm: + user_input = input('Are you sure you want to delete this data? Type "yes" to confirm: ') + if user_input.lower() != 'yes': + self.stdout.write(self.style.ERROR('Operation cancelled.')) + return + + try: + with transaction.atomic(): + # Delete in order to respect foreign key constraints + # 1. Delete PlaylistItem first (references VideoPlaylist) + deleted_playlist_items = PlaylistItem.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_playlist_items[0]} PlaylistItems')) + + # 2. Delete VideoPlaylist (may reference VideoCategory and VideoCollection through M2M) + deleted_playlists = VideoPlaylist.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_playlists[0]} VideoPlaylists')) + + # 3. Delete VideoCollection + deleted_collections = VideoCollection.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_collections[0]} VideoCollections')) + + # 4. Delete VideoCategory + deleted_categories = VideoCategory.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_categories[0]} VideoCategories')) + + self.stdout.write(self.style.SUCCESS('\n✓ All data deleted successfully!')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\n✗ Error during deletion: {str(e)}')) + raise diff --git a/apps/video/management/commands/create_video_categories.py b/apps/video/management/commands/create_video_categories.py new file mode 100644 index 0000000..b0c8061 --- /dev/null +++ b/apps/video/management/commands/create_video_categories.py @@ -0,0 +1,88 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.video.models import VideoCategory + + +class Command(BaseCommand): + help = 'Create video categories in Russian language' + + # Russian video categories + CATEGORIES_DATA = [ + { + 'title': 'Пророки и посланники', + 'order': 10 + }, + { + 'title': 'Имамы Ахль аль-Байт', + 'order': 20 + }, + { + 'title': 'Коранические истории', + 'order': 30 + }, + { + 'title': 'Исламская философия', + 'order': 40 + }, + { + 'title': 'Нравственность и этика', + 'order': 50 + }, + { + 'title': 'История ислама', + 'order': 60 + }, + { + 'title': 'Кербела и Ашура', + 'order': 70 + }, + { + 'title': 'Духовное развитие', + 'order': 80 + } + ] + + def add_arguments(self, parser): + parser.add_argument( + '--clean', + action='store_true', + help='Delete existing categories before creating new ones' + ) + + def handle(self, *args, **options): + clean = options.get('clean', False) + + if clean: + deleted_count = VideoCategory.objects.count() + VideoCategory.objects.all().delete() + self.stdout.write(self.style.WARNING(f'Deleted {deleted_count} existing categories')) + + try: + with transaction.atomic(): + created_categories = [] + + for category_data in self.CATEGORIES_DATA: + # Check if category already exists + title = category_data['title'] + category, created = VideoCategory.objects.get_or_create( + title=title, + defaults={ + 'order': category_data['order'], + 'status': True + } + ) + + if created: + self.stdout.write(self.style.SUCCESS(f'✓ Created category: {category.title}')) + created_categories.append(category) + else: + self.stdout.write(self.style.WARNING(f'⚠ Category already exists: {category.title}')) + + if created_categories: + self.stdout.write(self.style.SUCCESS(f'\n✓ Successfully created {len(created_categories)} categories!')) + else: + self.stdout.write(self.style.WARNING('\nNo new categories created (all already exist)')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\n✗ Error during creation: {str(e)}')) + raise diff --git a/apps/video/management/commands/create_video_playlists.py b/apps/video/management/commands/create_video_playlists.py new file mode 100644 index 0000000..eaa04a4 --- /dev/null +++ b/apps/video/management/commands/create_video_playlists.py @@ -0,0 +1,154 @@ +import random +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.video.models import Video, VideoPlaylist, PlaylistItem, VideoCategory + + +class Command(BaseCommand): + help = 'Create 10 video playlists in Russian language about prophets and imams, and add all videos to each playlist' + + # Russian playlist data about prophets and imams + # Each playlist has associated category titles + PLAYLISTS_DATA = [ + { + 'title': 'Жизнь Пророка Мухаммада (да благословит его Аллах)', + 'slogan': 'Изучение жизни последнего пророка', + 'description': 'Полная коллекция лекций о жизни, учениях и наследии Пророка Мухаммада (мир ему и благословение Аллаха). Узнайте о его миссии, характере и влиянии на человечество.', + 'categories': ['Пророки и посланники', 'История ислама'] + }, + { + 'title': 'Истории пророков в Коране', + 'slogan': 'Коранические повествования о посланниках Аллаха', + 'description': 'Глубокое изучение историй пророков, упомянутых в Священном Коране. От Адама до Мухаммада (мир им всем), узнайте об их испытаниях, учениях и вере.', + 'categories': ['Пророки и посланники', 'Коранические истории'] + }, + { + 'title': 'Имам Али: Врата знаний', + 'slogan': 'Мудрость и наследие первого Имама', + 'description': 'Исследование жизни, учений и мудрости Имама Али ибн Аби Талиба, двоюродного брата и зятя Пророка Мухаммада. Его речи, письма и руководство.', + 'categories': ['Имамы Ахль аль-Байт', 'Исламская философия'] + }, + { + 'title': 'Имам Хусейн и трагедия Кербелы', + 'slogan': 'Жертва ради истины и справедливости', + 'description': 'Полное понимание событий Ашуры и мученичества Имама Хусейна. Узнайте о его стойкости против угнетения и его вечном послании человечеству.', + 'categories': ['Имамы Ахль аль-Байт', 'Кербела и Ашура', 'История ислама'] + }, + { + 'title': 'Двенадцать Имамов Ахль аль-Байт', + 'slogan': 'Светильники руководства', + 'description': 'Всестороннее изучение жизни и учений двенадцати непогрешимых Имамов из рода Пророка. Их роль в сохранении истинного ислама.', + 'categories': ['Имамы Ахль аль-Байт', 'История ислама'] + }, + { + 'title': 'Фатима аз-Захра: Дочь Пророка', + 'slogan': 'Образец для верующих женщин', + 'description': 'Жизнь, добродетели и положение Фатимы аз-Захры, любимой дочери Пророка Мухаммада. Ее роль как матери Имамов и ее духовное величие.', + 'categories': ['Имамы Ахль аль-Байт', 'Нравственность и этика'] + }, + { + 'title': 'Имам Махди: Обещанный спаситель', + 'slogan': 'Ожидание и подготовка к появлению', + 'description': 'Понимание концепции Имама Махди, последнего Имама, который установит справедливость на земле. Признаки его появления и наша роль в ожидании.', + 'categories': ['Имамы Ахль аль-Байт', 'Духовное развитие'] + }, + { + 'title': 'Пророки и их чудеса', + 'slogan': 'Божественные знамения и доказательства', + 'description': 'Исследование чудес, дарованных пророкам Аллахом. От посоха Мусы до раскола луны Пророком Мухаммадом, узнайте о знамениях Всевышнего.', + 'categories': ['Пророки и посланники', 'Коранические истории'] + }, + { + 'title': 'Учения Ахль аль-Байт о нравственности', + 'slogan': 'Духовное совершенствование через Ислам', + 'description': 'Практические учения Пророка и Имамов о нравственности, этике и духовном росте. Применение исламских принципов в повседневной жизни.', + 'categories': ['Имамы Ахль аль-Байт', 'Нравственность и этика', 'Духовное развитие'] + }, + { + 'title': 'Имам Риза и его наследие', + 'slogan': 'Восьмой Имам и его вклад', + 'description': 'Жизнь, дискуссии и мученичество Имама Ризы, восьмого Имама. Его диалоги с учеными различных религий и его роль в распространении знаний.', + 'categories': ['Имамы Ахль аль-Байт', 'Исламская философия', 'История ислама'] + } + ] + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be created without actually creating' + ) + + def handle(self, *args, **options): + dry_run = options.get('dry_run', False) + + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN MODE - No actual creation will be performed')) + + # Get all videos + videos = list(Video.objects.filter(status=True).order_by('id')) + video_count = len(videos) + + if video_count == 0: + self.stdout.write(self.style.ERROR('No videos found in database. Please add videos first.')) + return + + self.stdout.write(f'\nFound {video_count} videos in database') + self.stdout.write(f'Creating {len(self.PLAYLISTS_DATA)} playlists...\n') + + if dry_run: + for idx, playlist_data in enumerate(self.PLAYLISTS_DATA, start=1): + self.stdout.write(f'{idx}. {playlist_data["title"]}') + self.stdout.write(f' Slogan: {playlist_data["slogan"]}') + self.stdout.write(f' Would add {video_count} videos to this playlist\n') + return + + try: + with transaction.atomic(): + created_playlists = [] + + for idx, playlist_data in enumerate(self.PLAYLISTS_DATA, start=1): + # Create playlist + playlist = VideoPlaylist.objects.create( + title=playlist_data['title'], + slogan=playlist_data['slogan'], + description=playlist_data['description'], + order=idx * 10, + status=True + ) + + # Add categories to playlist + category_titles = playlist_data.get('categories', []) + if category_titles: + categories = VideoCategory.objects.filter(title__in=category_titles) + playlist.categories.set(categories) + self.stdout.write(f' Added {categories.count()} categories') + + self.stdout.write(self.style.SUCCESS(f'✓ Created playlist: {playlist.title}')) + + # Add all videos to this playlist with random priority + playlist_items_created = 0 + for priority, video in enumerate(videos, start=1): + PlaylistItem.objects.create( + playlist=playlist, + video=video, + priority=priority + ) + playlist_items_created += 1 + + # Calculate and save total time + total_time = playlist.calculate_total_time() + playlist.total_time = total_time + playlist.save(update_fields=['total_time']) + + self.stdout.write(f' Added {playlist_items_created} videos to playlist') + self.stdout.write(f' Total duration: {total_time}\n') + + created_playlists.append(playlist) + + self.stdout.write(self.style.SUCCESS(f'\n✓ Successfully created {len(created_playlists)} playlists!')) + self.stdout.write(self.style.SUCCESS(f'✓ Each playlist contains all {video_count} videos')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'\n✗ Error during creation: {str(e)}')) + raise diff --git a/apps/video/management/commands/import_videos.py b/apps/video/management/commands/import_videos.py new file mode 100644 index 0000000..b661dc0 --- /dev/null +++ b/apps/video/management/commands/import_videos.py @@ -0,0 +1,358 @@ +import os +import json +import random +import subprocess +import tempfile +from datetime import datetime, time +from pathlib import Path + +import requests +from django.core.management.base import BaseCommand +from django.core.files import File +from django.db import transaction +from django.utils.text import slugify + +from apps.video.models import Video + + +class Command(BaseCommand): + help = 'Download videos from video_link.json and save them to Video model' + + # Russian titles related to prophets, Quran, and Hadith + RUSSIAN_TITLES = [ + "Жизнь Пророка Мухаммада (да благословит его Аллах)", + "История пророка Ибрахима", + "Коран и его значение в жизни мусульман", + "Хадисы Пророка о милосердии", + "Пророк Иса в исламской традиции", + "Коранические истории о пророках", + "Хадисы о важности знаний", + "Жизнь и миссия пророка Мусы", + "Толкование Корана: суры о вере", + "Пророк Нух и его призыв к единобожию", + "Хадисы о праведности и благочестии", + "Коранические принципы справедливости", + "История пророка Юсуфа", + "Хадисы о терпении и благодарности", + "Пророк Сулейман и его мудрость", + "Коран о морали и нравственности", + "Жизнь пророка Закарии", + "Хадисы о семье и родителях", + "Пророк Давуд и его псалмы", + "Коранические учения о добре и зле", + "Хадисы о щедрости и милосердии", + "История пророка Салиха", + "Коран о единобожии и вере", + "Пророк Худ и его народ", + "Хадисы о праведных поступках", + "Коранические истории о терпении", + "Жизнь пророка Яхьи", + "Хадисы о молитве и поклонении", + "Пророк Лут и его призыв", + "Коран о милости Аллаха", + "Хадисы о скромности и смирении", + "История пророка Шуайба", + "Коранические заповеди о честности", + "Пророк Идрис и его вознесение", + "Хадисы о братстве в исламе", + "Коран о судном дне", + "Жизнь пророка Исмаила", + "Хадисы о постоянстве в вере", + "Пророк Ильяс и его чудеса", + "Коранические притчи и их мудрость", + "Хадисы о покаянии и прощении", + "История пророка Айюба", + "Коран о защите слабых и угнетенных", + "Пророк Зу-ль-Кифль и его терпение", + "Хадисы о правдивости и искренности", + "Коранические учения о справедливом суде", + "Жизнь пророка Харуна", + "Хадисы о стремлении к знаниям", + "Пророк Юнус и его покаяние", + "Коран о величии Аллаха", + "Имам Хусейн и его жертва", + "Имам Али и его мудрость", + "Имам Хасан и путь мира", + "Фатима аз-Захра и ее святость", + "Имам Махди и ожидание", + "Ашура и значение Кербелы", + "Учения Ахль аль-Байт", + "Двенадцать имамов в шиизме", + "Имам Риза и его наследие", + ] + + def add_arguments(self, parser): + parser.add_argument( + '--json-file', + type=str, + default='video_link.json', + help='Path to video_link.json file' + ) + parser.add_argument( + '--skip-existing', + action='store_true', + help='Skip videos that already exist with the same slug' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be done without actually downloading' + ) + parser.add_argument( + '--limit', + type=int, + default=None, + help='Limit number of videos to process (for testing)' + ) + + def handle(self, *args, **options): + json_file = options['json_file'] + skip_existing = options['skip_existing'] + self.dry_run = options.get('dry_run', False) + limit = options.get('limit') + + if self.dry_run: + self.stdout.write(self.style.WARNING('DRY RUN MODE - No actual downloads or saves will be performed')) + + # Read JSON file + if not os.path.isabs(json_file): + # If relative path, make it relative to project root + from django.conf import settings + json_file = os.path.join(settings.BASE_DIR, json_file) + + if not os.path.exists(json_file): + self.stdout.write(self.style.ERROR(f'File not found: {json_file}')) + return + + with open(json_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + videos_list = data.get('videos', []) + youtube_links = data.get('youtube_links', []) + + processed_count = 0 + + # Process videos with slugs + for video_data in videos_list: + if limit and processed_count >= limit: + self.stdout.write(self.style.WARNING(f'Reached limit of {limit} videos')) + break + + slug = video_data.get('slug') + video_url = video_data.get('video') + + if not video_url: + self.stdout.write(self.style.WARNING(f'Skipping {slug}: No video URL')) + continue + + if skip_existing and Video.objects.filter(slug=slug).exists(): + self.stdout.write(self.style.WARNING(f'Skipping {slug}: Already exists')) + continue + + self.process_video(video_url, slug) + processed_count += 1 + + # Process youtube_links (direct links without slugs) + for idx, video_url in enumerate(youtube_links, start=1): + if limit and processed_count >= limit: + self.stdout.write(self.style.WARNING(f'Reached limit of {limit} videos')) + break + + # Generate slug from URL filename + filename = os.path.basename(video_url) + slug = slugify(os.path.splitext(filename)[0])[:50] + + if skip_existing and Video.objects.filter(slug=slug).exists(): + self.stdout.write(self.style.WARNING(f'Skipping {slug}: Already exists')) + continue + + self.process_video(video_url, slug) + processed_count += 1 + + self.stdout.write(self.style.SUCCESS(f'Processed {processed_count} videos successfully!')) + + def process_video(self, video_url, slug): + """Process a single video: download, extract thumbnail, save to database""" + self.stdout.write(f'\nProcessing: {slug}') + self.stdout.write(f' URL: {video_url}') + + if self.dry_run: + title = random.choice(self.RUSSIAN_TITLES) + self.stdout.write(f' [DRY RUN] Would save as: {title}') + return + + temp_dir = None + try: + # Create temporary directory + temp_dir = tempfile.mkdtemp() + + # Download video + video_path = self.download_video(video_url, temp_dir, slug) + if not video_path: + return + + # Extract thumbnail + thumbnail_path = self.extract_thumbnail(video_path, temp_dir, slug) + + # Get video duration + duration = self.get_video_duration(video_path) + + # Generate random title + title = random.choice(self.RUSSIAN_TITLES) + + # Ensure unique title by appending counter if needed + base_title = title + counter = 1 + while Video.objects.filter(title=title).exists(): + title = f"{base_title} ({counter})" + counter += 1 + + # Ensure unique slug by appending counter if needed + final_slug = slug + slug_counter = 1 + while Video.objects.filter(slug=final_slug).exists(): + final_slug = f"{slug}-{slug_counter}" + slug_counter += 1 + + if final_slug != slug: + self.stdout.write(self.style.WARNING(f' ⚠ Slug conflict, using: {final_slug}')) + + # Create Video object + with transaction.atomic(): + video = Video( + title=title, + slug=final_slug, + video_type=Video.VedioTypeChoices.VIDEO_FILE, + video_time=duration, + status=True, + ) + + # Save video file + with open(video_path, 'rb') as video_file: + video.video_file.save( + f'{slug}.mp4', + File(video_file), + save=False + ) + + # Save thumbnail if extracted + if thumbnail_path and os.path.exists(thumbnail_path): + with open(thumbnail_path, 'rb') as thumb_file: + video.thumbnail.save( + f'{slug}_thumb.jpg', + File(thumb_file), + save=False + ) + + video.save() + self.stdout.write(self.style.SUCCESS(f'✓ Saved: {title} (slug: {final_slug})')) + + except Exception as e: + self.stdout.write(self.style.ERROR(f'✗ Error processing {slug}: {str(e)}')) + + finally: + # Cleanup temporary files + if temp_dir and os.path.exists(temp_dir): + import shutil + shutil.rmtree(temp_dir, ignore_errors=True) + + def download_video(self, video_url, temp_dir, slug): + """Download video to temporary directory""" + try: + self.stdout.write(f' Downloading video...') + video_path = os.path.join(temp_dir, f'{slug}.mp4') + + response = requests.get(video_url, stream=True, timeout=300) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + last_percent = 0 + + with open(video_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + if total_size > 0: + percent = int((downloaded / total_size) * 100) + # Print progress every 10% + if percent >= last_percent + 10: + self.stdout.write(f' Progress: {percent}%') + last_percent = percent + + self.stdout.write(f' ✓ Downloaded: {downloaded / (1024*1024):.2f} MB') + return video_path + + except Exception as e: + self.stdout.write(self.style.ERROR(f' ✗ Download failed: {str(e)}')) + return None + + def extract_thumbnail(self, video_path, temp_dir, slug): + """Extract thumbnail from video using ffmpeg""" + try: + self.stdout.write(f' Extracting thumbnail...') + thumbnail_path = os.path.join(temp_dir, f'{slug}_thumb.jpg') + + # Extract frame at 1 second + cmd = [ + 'ffmpeg', + '-i', video_path, + '-ss', '00:00:01', + '-vframes', '1', + '-q:v', '2', + thumbnail_path, + '-y' + ] + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60 + ) + + if result.returncode == 0 and os.path.exists(thumbnail_path): + self.stdout.write(f' ✓ Thumbnail extracted') + return thumbnail_path + else: + self.stdout.write(self.style.WARNING(f' ⚠ Thumbnail extraction failed')) + return None + + except Exception as e: + self.stdout.write(self.style.WARNING(f' ⚠ Thumbnail error: {str(e)}')) + return None + + def get_video_duration(self, video_path): + """Get video duration using ffprobe""" + try: + cmd = [ + 'ffprobe', + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + video_path + ] + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30 + ) + + if result.returncode == 0: + duration_seconds = float(result.stdout.decode().strip()) + hours = int(duration_seconds // 3600) + minutes = int((duration_seconds % 3600) // 60) + seconds = int(duration_seconds % 60) + + self.stdout.write(f' ✓ Duration: {hours:02d}:{minutes:02d}:{seconds:02d}') + return time(hour=hours, minute=minutes, second=seconds) + else: + self.stdout.write(self.style.WARNING(f' ⚠ Could not determine duration, using default')) + return time(hour=0, minute=0, second=0) + + except Exception as e: + self.stdout.write(self.style.WARNING(f' ⚠ Duration error: {str(e)}, using default')) + return time(hour=0, minute=0, second=0) diff --git a/apps/video/migrations/0001_initial.py b/apps/video/migrations/0001_initial.py index ae6bb1f..95cd5a1 100644 --- a/apps/video/migrations/0001_initial.py +++ b/apps/video/migrations/0001_initial.py @@ -1,9 +1,18 @@ +<<<<<<< HEAD # Generated by Django 3.2.7 on 2025-03-21 22:06 from django.conf import settings from django.db import migrations, models import django.db.models.deletion import filer.fields.image +======= +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.image +from django.conf import settings +from django.db import migrations, models +>>>>>>> develop class Migration(migrations.Migration): @@ -16,6 +25,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( +<<<<<<< HEAD name='Video', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -37,6 +47,8 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +======= +>>>>>>> develop name='VideoCategory', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -68,6 +80,32 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( +<<<<<<< HEAD +======= + name='Video', + 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)), + ('description', models.TextField(null=True)), + ('video_type', models.CharField(choices=[('file', 'File'), ('youtube', 'Youtube')], default='file', max_length=255)), + ('video_file', models.FileField(blank=True, null=True, upload_to='video/videos/')), + ('video_url', models.CharField(blank=True, max_length=655, null=True)), + ('video_time', models.TimeField()), + ('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')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL)), + ('categories', models.ManyToManyField(blank=True, related_name='videos', to='video.videocategory', verbose_name='categories')), + ], + options={ + 'verbose_name': 'Video', + 'verbose_name_plural': 'Videos', + }, + ), + migrations.CreateModel( +>>>>>>> develop name='VideoInCollection', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -84,6 +122,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='videocollection', name='videos', +<<<<<<< HEAD field=models.ManyToManyField(related_name='collections', through='video.VideoInCollection', to='video.Video', verbose_name='videos'), ), migrations.AddField( @@ -95,5 +134,8 @@ class Migration(migrations.Migration): model_name='video', name='thumbnail', field=filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL), +======= + field=models.ManyToManyField(related_name='collections', through='video.VideoInCollection', to='video.video', verbose_name='videos'), +>>>>>>> develop ), ] diff --git a/apps/video/migrations/0002_alter_video_thumbnail.py b/apps/video/migrations/0002_alter_video_thumbnail.py new file mode 100755 index 0000000..7059e88 --- /dev/null +++ b/apps/video/migrations/0002_alter_video_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='video', + name='thumbnail', + field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='book_thumbnails/'), + ), + ] diff --git a/apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py b/apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py new file mode 100755 index 0000000..7a92be7 --- /dev/null +++ b/apps/video/migrations/0003_remove_videocollection_videos_middlevideocollection_and_more.py @@ -0,0 +1,87 @@ +# Generated by Django 5.1.8 on 2025-05-05 09:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0002_alter_video_thumbnail'), + ] + + operations = [ + migrations.RemoveField( + model_name='videocollection', + name='videos', + ), + migrations.CreateModel( + name='MiddleVideoCollection', + fields=[ + ], + options={ + 'verbose_name': 'Middle Section Video Collection', + 'verbose_name_plural': 'Middle Section Video Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('video.videocollection',), + ), + migrations.CreateModel( + name='PinnedVideoCollection', + fields=[ + ], + options={ + 'verbose_name': 'Pinned Video Collection', + 'verbose_name_plural': 'Pinned Video Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('video.videocollection',), + ), + migrations.AddField( + model_name='video', + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_videos', to='video.videocollection', verbose_name='collections'), + ), + migrations.AddField( + model_name='videocollection', + name='display_position', + field=models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position'), + ), + migrations.AddField( + model_name='videocollection', + name='pin_top', + field=models.BooleanField(default=True, verbose_name='pin top'), + ), + migrations.AddField( + model_name='videocollection', + name='slug', + field=models.SlugField(default='v_collection_1', max_length=255, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='videocollection', + name='summary', + field=models.CharField(blank=True, help_text='could be null', max_length=512, null=True), + ), + migrations.AddField( + model_name='videocollection', + name='thumbnail', + field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='video/collection/'), + ), + migrations.AlterField( + model_name='video', + name='thumbnail', + field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='video/thumbnails/'), + ), + migrations.AlterField( + model_name='videocollection', + name='title', + field=models.CharField(max_length=255), + ), + migrations.DeleteModel( + name='VideoInCollection', + ), + ] diff --git a/apps/video/migrations/0004_videocollection_order.py b/apps/video/migrations/0004_videocollection_order.py new file mode 100755 index 0000000..0d1a217 --- /dev/null +++ b/apps/video/migrations/0004_videocollection_order.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-05-05 09:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0003_remove_videocollection_videos_middlevideocollection_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='videocollection', + name='order', + field=models.IntegerField(default=0, verbose_name='order'), + ), + ] diff --git a/apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py b/apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py new file mode 100755 index 0000000..46c59e7 --- /dev/null +++ b/apps/video/migrations/0005_videoplaylist_alter_video_options_playlistitem.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.8 on 2025-05-05 13:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0004_videocollection_order'), + ] + + operations = [ + migrations.CreateModel( + name='VideoPlaylist', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('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': 'Video Playlist', + 'verbose_name_plural': 'Video Playlists', + }, + ), + migrations.AlterModelOptions( + name='video', + options={}, + ), + migrations.CreateModel( + name='PlaylistItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_appearances', to='video.video', verbose_name='video')), + ('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_items', to='video.videoplaylist', verbose_name='playlist')), + ], + options={ + 'verbose_name': 'Playlist Item', + 'verbose_name_plural': 'Playlist Items', + 'ordering': ['priority'], + 'unique_together': {('playlist', 'video')}, + }, + ), + ] diff --git a/apps/video/migrations/0006_alter_video_video_type.py b/apps/video/migrations/0006_alter_video_video_type.py new file mode 100755 index 0000000..b5bd4ca --- /dev/null +++ b/apps/video/migrations/0006_alter_video_video_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-05-06 00:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0005_videoplaylist_alter_video_options_playlistitem'), + ] + + operations = [ + migrations.AlterField( + model_name='video', + name='video_type', + field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=255), + ), + ] diff --git a/apps/video/migrations/0007_videoincollection_alter_video_collections.py b/apps/video/migrations/0007_videoincollection_alter_video_collections.py new file mode 100755 index 0000000..2d62d6f --- /dev/null +++ b/apps/video/migrations/0007_videoincollection_alter_video_collections.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.8 on 2025-05-06 11:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0006_alter_video_video_type'), + ] + + operations = [ + # First remove the existing collections field + migrations.RemoveField( + model_name='video', + name='collections', + ), + # Then create the VideoInCollection model + migrations.CreateModel( + name='VideoInCollection', + 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')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_videos', to='video.videocollection', verbose_name='collection')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_collections', to='video.video', verbose_name='video')), + ], + options={ + 'verbose_name': 'Video in Collection', + 'verbose_name_plural': 'Videos in Collections', + 'ordering': ['order'], + 'unique_together': {('collection', 'video')}, + }, + ), + # Finally add the collections field back with the through model + migrations.AddField( + model_name='video', + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_videos', through='video.VideoInCollection', to='video.videocollection', verbose_name='collections'), + ), + ] diff --git a/apps/video/migrations/0008_videocollection_videos.py b/apps/video/migrations/0008_videocollection_videos.py new file mode 100755 index 0000000..57754a1 --- /dev/null +++ b/apps/video/migrations/0008_videocollection_videos.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-05-06 12:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0007_videoincollection_alter_video_collections'), + ] + + operations = [ + migrations.AddField( + model_name='videocollection', + name='videos', + field=models.ManyToManyField(related_name='related_collections_video', through='video.VideoInCollection', to='video.video', verbose_name='Videos'), + ), + ] diff --git a/apps/video/migrations/0009_auto_20251130_1756.py b/apps/video/migrations/0009_auto_20251130_1756.py new file mode 100644 index 0000000..0323c9e --- /dev/null +++ b/apps/video/migrations/0009_auto_20251130_1756.py @@ -0,0 +1,93 @@ +# Generated by Django 3.2.4 on 2025-11-30 17:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0008_videocollection_videos'), + ] + + operations = [ + migrations.AlterModelOptions( + name='videoplaylist', + options={'ordering': ['order', '-created_at'], 'verbose_name': 'Video Playlist', 'verbose_name_plural': 'Video Playlists'}, + ), + migrations.RemoveField( + model_name='video', + name='categories', + ), + migrations.RemoveField( + model_name='video', + name='collections', + ), + migrations.AddField( + model_name='videoplaylist', + name='categories', + field=models.ManyToManyField(blank=True, related_name='playlists', to='video.VideoCategory', verbose_name='categories'), + ), + migrations.AddField( + model_name='videoplaylist', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='description'), + ), + migrations.AddField( + model_name='videoplaylist', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='order'), + ), + migrations.AddField( + model_name='videoplaylist', + name='slogan', + field=models.CharField(blank=True, max_length=512, null=True, verbose_name='slogan'), + ), + migrations.AddField( + model_name='videoplaylist', + name='slug', + field=models.SlugField(allow_unicode=True, blank=True, null=True, unique=True, verbose_name='slug'), + ), + migrations.AddField( + model_name='videoplaylist', + name='status', + field=models.BooleanField(default=True, verbose_name='status'), + ), + migrations.AddField( + model_name='videoplaylist', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='video/playlist/thumbnails/', verbose_name='thumbnail'), + ), + migrations.AddField( + model_name='videoplaylist', + name='total_time', + field=models.DurationField(blank=True, null=True, verbose_name='total time'), + ), + migrations.AddField( + model_name='videoplaylist', + name='view_count', + field=models.PositiveBigIntegerField(default=0, verbose_name='view count'), + ), + migrations.CreateModel( + name='VideoPlaylistInCollection', + 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')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_playlists', to='video.videocollection', verbose_name='collection')), + ('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlist_collections', to='video.videoplaylist', verbose_name='playlist')), + ], + options={ + 'verbose_name': 'Video Playlist in Collection', + 'verbose_name_plural': 'Video Playlists in Collections', + 'ordering': ['order'], + 'unique_together': {('collection', 'playlist')}, + }, + ), + migrations.AddField( + model_name='videoplaylist', + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_playlists', through='video.VideoPlaylistInCollection', to='video.VideoCollection', verbose_name='collections'), + ), + ] diff --git a/apps/video/migrations/0010_remove_videoincollection_model.py b/apps/video/migrations/0010_remove_videoincollection_model.py new file mode 100644 index 0000000..ea3959c --- /dev/null +++ b/apps/video/migrations/0010_remove_videoincollection_model.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.4 on 2025-12-01 13:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0009_auto_20251130_1756'), + ] + + operations = [ + migrations.RemoveField( + model_name='videocollection', + name='videos', + ), + migrations.DeleteModel( + name='VideoInCollection', + ), + ] diff --git a/apps/video/models.py b/apps/video/models.py index 467351e..de5770e 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField +from utils import generate_slug_for_model class VideoCategory(models.Model): @@ -15,6 +16,11 @@ class VideoCategory(models.Model): def __str__(self): return self.title + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(VideoCategory, self.title) + super().save(*args, **kwargs) + class Meta: verbose_name = _('Video Category') verbose_name_plural = _('Video Categories') @@ -22,60 +28,67 @@ class VideoCategory(models.Model): class VideoCollection(models.Model): - title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere") + class DisplayPosition(models.TextChoices): + PINNED = 'pinned', _('Pinned') + MIDDLE = 'middle', _('Middle Section') + + title = models.CharField(max_length=255) + 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='video/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')) - videos = models.ManyToManyField( - "Video", - through='VideoInCollection', - related_name='collections', - verbose_name=_('videos'), - ) + 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(VideoCollection, self.title) + super().save(*args, **kwargs) + + class Meta: verbose_name = _('Video Collection') verbose_name_plural = _('Video Collections') +class PinnedVideoCollection(VideoCollection): + class Meta: + proxy = True + verbose_name = _('Pinned Video Collection') + verbose_name_plural = _('Pinned Video Collections') -class VideoInCollection(models.Model): - video_collection = models.ForeignKey( - "VideoCollection", on_delete=models.CASCADE, related_name='videos_in_collection', verbose_name=_('video collection') - ) - video = models.ForeignKey( - "Video", on_delete=models.CASCADE, related_name='collections_videos', verbose_name=_('video') - ) - priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) - - def __str__(self): - return f"{self.video_collection.title} - {self.video.title} (Priority: {self.priority})" +class MiddleVideoCollection(VideoCollection): class Meta: - verbose_name = _('Video in Collection') - verbose_name_plural = _('Videos in Collection') - ordering = ['priority'] + proxy = True + verbose_name = _('Middle Section Video Collection') + verbose_name_plural = _('Middle Section Video Collections') + + class Video(models.Model): - class vdeo_type(models.TextChoices): - FILE = 'file' - YOUTUBE = 'youtube' + class VedioTypeChoices(models.TextChoices): + YOUTUBE_LINK = 'youtube_link', 'Youtube Link' + VIDEO_FILE = 'video_file', 'Video File' title = models.CharField(max_length=255, null=True) slug = models.SlugField(allow_unicode=True, unique=True) - thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_( - 'image allowed' - )) + thumbnail = models.ImageField(upload_to='video/thumbnails/', null=True, blank=True, help_text=_('image allowed')) description = models.TextField(null=True) - categories = models.ManyToManyField( - VideoCategory, - related_name='videos', - verbose_name=_('categories'), - blank=True, - ) - video_type = models.CharField(max_length=255, choices=vdeo_type.choices, default=vdeo_type.FILE) + + video_type = models.CharField(max_length=255, choices=VedioTypeChoices.choices) video_file = models.FileField(upload_to='video/videos/', null=True, blank=True) video_url = models.CharField(max_length=655, null=True, blank=True) video_time = models.TimeField() @@ -96,8 +109,125 @@ class Video(models.Model): 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(Video, self.title) + super().save(*args, **kwargs) + + + +class VideoPlaylist(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(allow_unicode=True, unique=True, null=True, blank=True, verbose_name=_('slug')) + slogan = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('slogan')) + description = models.TextField(null=True, blank=True, verbose_name=_('description')) + thumbnail = models.ImageField(upload_to='video/playlist/thumbnails/', null=True, blank=True, verbose_name=_('thumbnail')) + + categories = models.ManyToManyField( + VideoCategory, + related_name='playlists', + verbose_name=_('categories'), + blank=True, + ) + collections = models.ManyToManyField( + VideoCollection, + through='VideoPlaylistInCollection', + related_name='related_playlists', + verbose_name=_('collections'), + blank=True + ) + + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + status = models.BooleanField(default=True, verbose_name=_('status')) + view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + total_time = models.DurationField(null=True, blank=True, verbose_name=_('total time')) + + 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): + """Increment the view count for this playlist""" + self.view_count += 1 + self.save(update_fields=['view_count']) + return self.view_count + + def calculate_total_time(self): + """Calculate total duration of all videos in this playlist""" + from datetime import timedelta + total_seconds = 0 + + for item in self.playlist_items.select_related('video'): + video_time = item.video.video_time + total_seconds += video_time.hour * 3600 + video_time.minute * 60 + video_time.second + + return timedelta(seconds=total_seconds) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(VideoPlaylist, self.title) + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('Video Playlist') + verbose_name_plural = _('Video Playlists') + ordering = ['order', '-created_at'] + +class VideoPlaylistInCollection(models.Model): + collection = models.ForeignKey( + VideoCollection, + on_delete=models.CASCADE, + related_name='collection_playlists', + verbose_name=_('collection') + ) + playlist = models.ForeignKey( + VideoPlaylist, + on_delete=models.CASCADE, + related_name='playlist_collections', + verbose_name=_('playlist') + ) + 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.playlist.title}" + + class Meta: + verbose_name = _('Video Playlist in Collection') + verbose_name_plural = _('Video Playlists in Collections') + ordering = ['order'] + unique_together = ['collection', 'playlist'] + + +class PlaylistItem(models.Model): + playlist = models.ForeignKey( + VideoPlaylist, + on_delete=models.CASCADE, + related_name='playlist_items', + verbose_name=_('playlist') + ) + video = models.ForeignKey( + Video, + on_delete=models.CASCADE, + related_name='playlist_appearances', + verbose_name=_('video') + ) + priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) + + 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.playlist.title} - {self.video.title} (Priority: {self.priority})" + class Meta: - verbose_name = _('Video') - verbose_name_plural = _('Videos') + verbose_name = _('Playlist Item') + verbose_name_plural = _('Playlist Items') + ordering = ['priority'] + unique_together = ['playlist', 'video'] diff --git a/apps/video/serializers.py b/apps/video/serializers.py index 85d95a9..97b097d 100644 --- a/apps/video/serializers.py +++ b/apps/video/serializers.py @@ -1,70 +1,277 @@ from rest_framework import serializers -from .models import VideoCategory, Video, VideoCollection, VideoInCollection +from utils import get_thumbs +from .models import VideoCategory, Video, VideoCollection, VideoPlaylist, PlaylistItem, PinnedVideoCollection +from apps.bookmark.serializers import * class VideoCategoryListSerializer(serializers.ModelSerializer): - video_count = serializers.SerializerMethodField() + playlist_count = serializers.SerializerMethodField() class Meta: model = VideoCategory - fields = ['id', 'title', 'slug', 'video_count'] + fields = ['id', 'title', 'slug', 'playlist_count'] - def get_video_count(self, obj): - return obj.videos.filter(status=True).count() + def get_playlist_count(self, obj): + return obj.playlists.filter(status=True).count() class VideoListSerializer(serializers.ModelSerializer): - categories = VideoCategoryListSerializer(many=True, read_only=True) - + thumbnail = serializers.SerializerMethodField() + video_file = serializers.SerializerMethodField() + class Meta: model = Video - fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_time', - 'view_count', 'categories', 'created_at'] + fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', + 'video_file', 'video_url', 'video_time', 'view_count', 'created_at'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_video_file(self, obj): + """Get full URL for video file if it exists""" + if obj.video_file: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.video_file.url) + return obj.video_file.url + return None + + +class VideoPlaylistListSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + total_time_formatted = serializers.SerializerMethodField() + + class Meta: + model = VideoPlaylist + fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'view_count', 'total_time_formatted', 'order', 'created_at'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_total_time_formatted(self, obj): + """Format total_time as HH:MM:SS string""" + if obj.total_time: + total_seconds = int(obj.total_time.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return "00:00:00" + + + + + + +class VideoPlaylistDetailSerializer(serializers.ModelSerializer): + categories = VideoCategoryListSerializer(many=True, read_only=True) + thumbnail = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() + user_rate = serializers.SerializerMethodField() + average_rate = serializers.SerializerMethodField() + videos = serializers.SerializerMethodField() + total_time_formatted = serializers.SerializerMethodField() + + class Meta: + model = VideoPlaylist + fields = ['id', 'title', 'slug', 'thumbnail', 'slogan', 'description', + 'view_count', 'total_time_formatted', 'order', 'status', + 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', 'videos'] + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + def get_total_time_formatted(self, obj): + """Format total_time as HH:MM:SS string""" + if obj.total_time: + total_seconds = int(obj.total_time.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return "00:00:00" + def get_bookmark(self, obj): + """Get bookmark information for this playlist.""" + request = self.context.get('request') + user = request.user if request else None + book_mark = BookmarkStatusSerializer.get_bookmark_info( + obj=obj, + user=user, + service='video_playlist' + ) + return book_mark.get('is_bookmarked', False) + + def get_user_rate(self, obj): + """Get rate information for this playlist from the current user.""" + from apps.bookmark.models.rate import Rate + + 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 + } + + rate_info = Rate.get_user_rate( + user=user, + service='video_playlist', + content_id=obj.id + ) + + return rate_info + + def get_average_rate(self, obj): + """Get the average rate for this playlist.""" + from apps.bookmark.models.rate import Rate + + return Rate.get_average_rate( + service='video_playlist', + content_id=obj.id + ) + + def get_videos(self, obj): + """Get all videos in this playlist ordered by priority.""" + videos = Video.objects.filter( + playlist_appearances__playlist=obj + ).distinct().order_by('playlist_appearances__priority') + + return VideoListSerializer( + videos, + many=True, + context=self.context + ).data class VideoDetailSerializer(serializers.ModelSerializer): - related_videos = serializers.SerializerMethodField() categories = VideoCategoryListSerializer(many=True, read_only=True) + thumbnail = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() + user_rate = serializers.SerializerMethodField() + average_rate = serializers.SerializerMethodField() + is_in_playlist = serializers.SerializerMethodField() + playlist_videos = serializers.SerializerMethodField() class Meta: model = Video fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', 'video_file', 'video_url', 'video_time', 'view_count', - 'categories', 'created_at', 'related_videos'] - - - def get_related_videos(self, obj): - # Get all collections that contain this video - collections = obj.collections.all() - - if collections.exists(): - # Get all videos from all collections that contain this video - related_videos = [] - video_ids = set() # To track unique videos - - for collection in collections: - # Get all videos in this collection ordered by priority - videos_in_collection = VideoInCollection.objects.filter( - video_collection=collection - ).exclude(video=obj).order_by('priority') - - # Add videos to our list if not already added - for vic in videos_in_collection: - if vic.video.id not in video_ids: - related_videos.append(vic.video) - video_ids.add(vic.video.id) - - # Return the related videos using VideoListSerializer - return VideoListSerializer(related_videos, many=True).data - - # # If not in a collection, return videos from the same category - # elif obj.category: - # related = Video.objects.filter( - # category=obj.category, - # status=True - # ).exclude(id=obj.id)[:5] - # return VideoListSerializer(related, many=True).data - return [] \ No newline at end of file + 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', + 'is_in_playlist', 'playlist_videos'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_bookmark(self, obj): + """ + Get bookmark information for this book. + """ + # 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='video' + ) + return book_mark.get('is_bookmarked', False) + + def get_user_rate(self, obj): + """ + Get rate information for this book 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='video', + content_id=obj.id + ) + + return rate_info + + def get_average_rate(self, obj): + """ + Get the average rate for this video. + """ + from apps.bookmark.models.rate import Rate + + # Get average rate information using the Rate model + return Rate.get_average_rate( + service='video', + content_id=obj.id + ) + + def get_is_in_playlist(self, obj): + """ + Check if the video is in any playlist. + Returns True if the video is in at least one playlist, False otherwise. + """ + return PlaylistItem.objects.filter(video=obj).exists() + + def get_playlist_videos(self, obj): + """ + If the video is in a playlist, return all videos from the first playlist it belongs to, + excluding the current video itself. Videos are ordered by their priority in the playlist. + Returns null if the video is not in any playlist. + """ + # Check if the video is in any playlist + if not self.get_is_in_playlist(obj): + return None + + # Get the first playlist that contains this video + playlist_item = PlaylistItem.objects.filter(video=obj).first() + if not playlist_item: + return None + + playlist = playlist_item.playlist + + # Get all videos in this playlist except the current one, ordered by priority + playlist_videos = Video.objects.filter( + playlist_appearances__playlist=playlist + ).exclude( + id=obj.id + ).distinct().order_by('playlist_appearances__priority') + + # Serialize the videos + return VideoListSerializer( + playlist_videos, + many=True, + context=self.context + ).data + + +class PinnedVideoCollectionSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = VideoCollection + fields = ['id', 'title', 'slug', 'summary', 'thumbnail', 'order', 'created_at'] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + +class MiddleVideoCollectionSerializer(serializers.ModelSerializer): + playlists = serializers.SerializerMethodField() + + class Meta: + model = VideoCollection + fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'pin_top','playlists') + + def get_playlists(self, obj): + playlists = obj.related_playlists.filter(status=True).order_by('order', '-created_at') + return VideoPlaylistListSerializer(playlists, many=True, context=self.context).data diff --git a/apps/video/urls.py b/apps/video/urls.py index e42154f..f00325b 100644 --- a/apps/video/urls.py +++ b/apps/video/urls.py @@ -1,12 +1,17 @@ -from django.urls import path -from .views import VideoCategoryListAPIView, VideoListAPIView, VideoDetailAPIView +from django.urls import path, re_path +from .views import * app_name = 'video' urlpatterns = [ path('categories/', VideoCategoryListAPIView.as_view(), name='category-list'), - - path('list/', VideoListAPIView.as_view(), name='video-list'), - - path('detail//', VideoDetailAPIView.as_view(), name='video-detail'), + path('pinned-collections/', PinnedVideoCollectionListView.as_view(), name='pinned-collection-list'), + path('collections/', MiddleVideoCollectionListView.as_view(), name='collection-list'), + + path('playlists/', VideoPlaylistListAPIView.as_view(), name='playlist-list'), + re_path(r'playlists/(?P[\w-]+)/$', VideoPlaylistDetailAPIView.as_view(), name='playlist-detail'), + + # Keep old video endpoints for backward compatibility if needed + path('list/', VideoPlaylistListAPIView.as_view(), name='video-list'), + re_path(r'detail/(?P[\w-]+)/$', VideoPlaylistDetailAPIView.as_view(), name='video-detail'), ] \ No newline at end of file diff --git a/apps/video/views.py b/apps/video/views.py index 3e90c7e..a63a493 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -1,49 +1,267 @@ from rest_framework import generics, status from rest_framework.response import Response -from .models import VideoCategory, Video -from .serializers import VideoCategoryListSerializer, VideoListSerializer, VideoDetailSerializer +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.video.models import * +from apps.video.serializers import * class VideoCategoryListAPIView(generics.ListAPIView): """ - API view to list all video categories with their video counts + API view to list all video categories """ serializer_class = VideoCategoryListSerializer + @swagger_auto_schema( + operation_description="Get a list of all active video categories", + tags=["Dobodbi - Video"], + responses={ + 200: openapi.Response( + description="List of video categories", + schema=VideoCategoryListSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + """ + Optimized queryset with prefetch_related for playlists + """ + return VideoCategory.objects.filter(status=True).prefetch_related( + 'playlists' + ).order_by('order') + + + +class PinnedVideoCollectionListView(generics.ListAPIView): + serializer_class = PinnedVideoCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = NoPagination + + @swagger_auto_schema( + operation_description="Get a list of pinned video collections", + tags=["Dobodbi - Video"], + responses={ + 200: openapi.Response( + description="List of pinned video collections", + schema=PinnedVideoCollectionSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return PinnedVideoCollection.objects.filter( + status=True, + display_position=VideoCollection.DisplayPosition.PINNED + ).order_by('-order', '-id') + + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + categories_count = VideoCategory.objects.filter(status=True).count() + from apps.bookmark.models import Bookmark + bookmarks_count = Bookmark.objects.filter( + service=Bookmark.ServiceChoices.VIDEO, + ).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 MiddleVideoCollectionListView(generics.ListAPIView): + serializer_class = MiddleVideoCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = NoPagination + + @swagger_auto_schema( + operation_description="Get a list of middle video collections", + tags=["Dobodbi - Video"], + responses={ + 200: openapi.Response( + description="List of middle video collections", + schema=MiddleVideoCollectionSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): - return VideoCategory.objects.filter(status=True).order_by('order') + return VideoCollection.objects.filter( + status=True, + display_position=VideoCollection.DisplayPosition.MIDDLE + ).order_by('order') -class VideoListAPIView(generics.ListAPIView): +class VideoPlaylistListAPIView(generics.ListAPIView): """ - API view to list all videos, with optional category filtering + API view to list all video playlists, with optional filtering by category or collection """ - serializer_class = VideoListSerializer + serializer_class = VideoPlaylistListSerializer + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_description="Get a list of video playlists with optional filtering", + tags=["Dobodbi - Video"], + manual_parameters=[ + openapi.Parameter( + name='category', + in_=openapi.IN_QUERY, + description='Filter playlists by category slug', + type=openapi.TYPE_STRING, + required=False + ), + openapi.Parameter( + name='collection', + in_=openapi.IN_QUERY, + description='Filter playlists by collection slug', + type=openapi.TYPE_STRING, + required=False + ), + openapi.Parameter( + name='is_bookmark', + in_=openapi.IN_QUERY, + description='Filter playlists that are bookmarked by the user (true/false)', + type=openapi.TYPE_BOOLEAN, + required=False + ), + openapi.Parameter( + name='search', + in_=openapi.IN_QUERY, + description='Search playlists by title', + type=openapi.TYPE_STRING, + required=False + ) + ], + responses={ + 200: openapi.Response( + description="List of video playlists", + schema=VideoPlaylistListSerializer(many=True) + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) def get_queryset(self): - queryset = Video.objects.filter(status=True).order_by('-created_at') + queryset = VideoPlaylist.objects.filter(status=True).order_by('order', '-created_at') + + # 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_slug = self.request.query_params.get('category', None) if category_slug: - queryset = queryset.filter(category__slug=category_slug) + queryset = queryset.filter(categories__slug=category_slug) + + # Filter by collection if provided + collection_slug = self.request.query_params.get('collection', None) + if collection_slug: + queryset = queryset.filter(collections__slug=collection_slug) + + 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 playlist IDs for the current user + bookmarked_ids = Bookmark.objects.filter( + user=self.request.user, + service=Bookmark.ServiceChoices.VIDEO_PLAYLIST, + status=True + ).values_list('content_id', flat=True) + + # Filter playlists by these IDs + queryset = queryset.filter(id__in=bookmarked_ids) + sort = self.request.query_params.get('sort', '-created_at') + allowed_sorts = [ + 'created_at', '-created_at', 'view_count', '-view_count', + 'title', '-title','order' , 'order', + 'total_time', '-total_time','-created_at','created_at' + ] + + if sort in allowed_sorts: + # Handle multiple sort fields (e.g., '-pin,-created_at') + if ',' in sort: + queryset = queryset.order_by(*sort.split(',')) + else: + queryset = queryset.order_by(sort) + else: + queryset = queryset.order_by('-created_at') return queryset +class VideoPlaylistDetailAPIView(generics.RetrieveAPIView): + serializer_class = VideoPlaylistDetailSerializer + permission_classes = (IsAuthenticated,) + lookup_field = 'slug' + + @swagger_auto_schema( + operation_description="Get video playlist details by slug", + tags=["Dobodbi - Video"], + responses={ + 200: openapi.Response( + description="Video playlist details", + schema=VideoPlaylistDetailSerializer() + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return VideoPlaylist.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) + + class VideoDetailAPIView(generics.RetrieveAPIView): - """ - API view to get video details, including related videos from the same collection - """ serializer_class = VideoDetailSerializer + permission_classes = (IsAuthenticated,) lookup_field = 'slug' - + + @swagger_auto_schema( + operation_description="Get video details by slug", + tags=["Dobodbi - Video"], + responses={ + 200: openapi.Response( + description="Video details", + schema=VideoDetailSerializer() + ) + } + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): return Video.objects.filter(status=True) def retrieve(self, request, *args, **kwargs): instance = self.get_object() - # Increment view count instance.increment_view_count() serializer = self.get_serializer(instance) return Response(serializer.data) + diff --git a/city_detection_ip.py b/city_detection_ip.py new file mode 100644 index 0000000..c1310f0 --- /dev/null +++ b/city_detection_ip.py @@ -0,0 +1,449 @@ +import os +import time +import geoip2.database +from pathlib import Path +from django.db import connection +from django.db.models import Q +from django.core.cache import cache +from django.db import transaction +import logging + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.develop') +from django.core.wsgi import get_wsgi_application + +application = get_wsgi_application() + +from apps.account.models import LoginHistory + +# Configure logging +logger = logging.getLogger(__name__) + +# GeoLite2 database paths +CITY_DB_PATH = Path("utils/country_city_db/GeoLite2-City.mmdb") + +# Special coordinates +SPECIAL_COORDINATES = [ + (32.616565, 44.03462), + (51.5287718, -0.2416802), + (40.3947021, 49.78492), + (55.751199, 37.614706), + (48.8589466, 2.2769956), + (40.4381311, -3.8196194), + (-6.2295712, 106.759478), + (33.6158004, 72.8059198) +] + +def get_location_by_coordinates_optimized(lat, lon): + """ + Optimized version with special coordinates handling. + Handles special coordinates correctly before geo lookup. + """ + try: + # Quick validation + if not lat or not lon: + return None + + lat, lon = float(lat), float(lon) + + # Check if coordinates are in special list - should use IP detection instead + for special_lat, special_lon in SPECIAL_COORDINATES: + if abs(lat - special_lat) < 0.001 and abs(lon - special_lon) < 0.001: + # These coordinates should use IP detection, not geo lookup + # Return None to trigger fallback to original method + logger.debug(f"Special coordinate detected: ({lat}, {lon}) - skipping geo lookup") + return None + + # Simple cache key + cache_key = f'geo_{round(lat, 2)}_{round(lon, 2)}' + + # Try cache first (no exception handling for speed) + cached_result = cache.get(cache_key) + if cached_result is not None: + return cached_result + + # Simple bounding box (larger range for better coverage) + lat_range = 3.0 # ~330km + lon_range = 3.0 + + lat_min = lat - lat_range + lat_max = lat + lat_range + lon_min = lon - lon_range + lon_max = lon + lon_range + + # Query with population weighting to prefer larger cities + with connection.cursor() as cursor: + # First, let's get debug information about nearby cities + cursor.execute(""" + WITH bounded_cities AS ( + SELECT name, country_code, latitude, longitude, population + FROM geonames_city + WHERE feature_class = 'P' + AND latitude BETWEEN %s AND %s + AND longitude BETWEEN %s AND %s + ), + distance_calc AS ( + SELECT name, country_code, population, + (6371 * acos(least(1, greatest(-1, + cos(radians(%s)) * cos(radians(latitude)) * + cos(radians(longitude) - radians(%s)) + + sin(radians(%s)) * sin(radians(latitude)) + )))) AS distance + FROM bounded_cities + ) + SELECT name, country_code, population, distance + FROM distance_calc + WHERE distance <= 100 + ORDER BY distance + LIMIT 10 + """, [lat_min, lat_max, lon_min, lon_max, lat, lon, lat]) + + debug_results = cursor.fetchall() + if debug_results: + logger.info(f"🔍 Top 10 nearby cities for coordinates ({lat}, {lon}):") + for name, cc, pop, dist in debug_results: + logger.info(f" 📍 {name} ({cc}): population={pop:,}, distance={dist:.2f}km") + + # Now get the best city using a weighted approach + # Prefer cities with larger population within reasonable distance + cursor.execute(""" + WITH bounded_cities AS ( + SELECT name, country_code, latitude, longitude, population + FROM geonames_city + WHERE feature_class = 'P' + AND latitude BETWEEN %s AND %s + AND longitude BETWEEN %s AND %s + AND population IS NOT NULL + AND population > 0 + ), + distance_calc AS ( + SELECT name, country_code, population, + (6371 * acos(least(1, greatest(-1, + cos(radians(%s)) * cos(radians(latitude)) * + cos(radians(longitude) - radians(%s)) + + sin(radians(%s)) * sin(radians(latitude)) + )))) AS distance + FROM bounded_cities + ), + scored_cities AS ( + SELECT name, country_code, distance, population, + -- Score: prefer closer cities, but weight population heavily + -- Cities within 30km: prioritize by population + -- Cities beyond 30km: balance distance and population + CASE + WHEN distance <= 30 THEN population / (distance + 1) + ELSE population / POWER(distance, 2) + END AS score + FROM distance_calc + WHERE distance <= 100 + ) + SELECT name, country_code + FROM scored_cities + ORDER BY score DESC + LIMIT 1 + """, [lat_min, lat_max, lon_min, lon_max, lat, lon, lat]) + + result = cursor.fetchone() + + if result: + name, country_code = result + logger.info(f"✅ Selected city: {name} ({country_code}) for coordinates ({lat}, {lon})") + response = { + 'status': 'success', + 'city': name, + 'countryCode': country_code + } + + # Cache for 24 hours + cache.set(cache_key, response, 86400) + return response + else: + logger.warning(f"⚠️ No city found within 100km for coordinates ({lat}, {lon})") + # Cache None for 1 hour + cache.set(cache_key, None, 3600) + return None + + except Exception: + # Fallback to original method on any error + return get_location_by_coordinates_original(lat, lon) + + +def get_location_by_coordinates_original(lat, lon): + """Original implementation as fallback""" + try: + with connection.cursor() as cursor: + cursor.execute(""" + WITH distance_calc AS ( + SELECT name, country_code, latitude, longitude, + (6371 * acos(least(1, greatest(-1, cos(radians(%s)) * cos(radians(latitude)) * + cos(radians(longitude) - radians(%s)) + + sin(radians(%s)) * sin(radians(latitude)))))) AS distance + FROM geonames_city + WHERE feature_class = 'P' + ) + SELECT name, country_code + FROM distance_calc + WHERE distance <= 300 + ORDER BY distance + LIMIT 1 + """, [lat, lon, lat]) + + result = cursor.fetchone() + + if result: + name, country_code = result + logger.info(f"🔄 Fallback method selected city: {name} ({country_code}) for coordinates ({lat}, {lon})") + return { + 'status': 'success', + 'city': name, + 'countryCode': country_code + } + return None + + except Exception as e: + logger.error(f"❌ Error in fallback method for coordinates ({lat}, {lon}): {str(e)}") + return None + + +def get_location_by_coordinates(lat, lon): + """ + Main function with smart fallback strategy. + Try optimized first, fallback to original if needed. + """ + # Try optimized version first + result = get_location_by_coordinates_optimized(lat, lon) + + # If optimized fails, use original as fallback + if result is None: + result = get_location_by_coordinates_original(lat, lon) + + return result + +def get_location_by_ip(ip): + """Get location from IP using MaxMind MMDB file directly""" + try: + if not CITY_DB_PATH.exists(): + return None + + with geoip2.database.Reader(CITY_DB_PATH) as reader: + response = reader.city(ip) + if response and response.country: + # Validate city name - check if it's not a subdivision + city_name = None + if response.city and response.city.name: + 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 + + return { + 'status': 'success', + 'countryCode': response.country.iso_code, + 'city': city_name + } + return None + + except Exception: + return None + +def update_login_history_optimized(): + """ + Optimized version with batch processing and better error handling. + Processes records in batches to reduce database load and improve performance. + """ + logger.info("Starting optimized login history update...") + + # Query for login histories that need updating + special_records = ( + LoginHistory.objects + .exclude(location_method="IP_DETECTION") + .exclude(lat__isnull=True) + .exclude(lon__isnull=True) + .filter(lat__in=[lat for lat, _ in SPECIAL_COORDINATES], lon__in=[lon for _, lon in SPECIAL_COORDINATES]) + [:1000] # Limit batch size + ) + + normal_records = ( + LoginHistory.objects + .exclude(location_method="IP_DETECTION") + .exclude(lat__isnull=True) + .exclude(lon__isnull=True) + .exclude(lat__in=[lat for lat, _ in SPECIAL_COORDINATES], lon__in=[lon for _, lon in SPECIAL_COORDINATES]) + [:1000] # Limit batch size + ) + + # Process special coordinates records (with IP) in batches + special_updates = [] + for login in special_records: + try: + location_data = get_location_by_ip(login.ip) + if location_data and location_data['status'] == 'success': + login.country = location_data['countryCode'] + login.city = location_data['city'] + login.location_method = 'IP_DETECTION' + special_updates.append(login) + + # Batch update every 50 records + if len(special_updates) >= 50: + with transaction.atomic(): + LoginHistory.objects.bulk_update( + special_updates, + ['country', 'city', 'location_method'] + ) + logger.info(f"Updated {len(special_updates)} special coordinate records") + special_updates = [] + except Exception as e: + logger.error(f"Error processing special record {login.id}: {e}") + continue + + # Final batch update for remaining special records + if special_updates: + with transaction.atomic(): + LoginHistory.objects.bulk_update( + special_updates, + ['country', 'city', 'location_method'] + ) + logger.info(f"Updated final {len(special_updates)} special coordinate records") + + # Process normal coordinates records (with GeoNames) in batches + normal_updates = [] + processed_normal = 0 + for login in normal_records: + try: + location_data = get_location_by_coordinates(login.lat, login.lon) + if location_data and location_data['status'] == 'success': + login.country = location_data['countryCode'] + login.city = location_data['city'] + login.location_method = 'COORDINATES' + normal_updates.append(login) + processed_normal += 1 + + # Batch update every 20 records (smaller batch for geo queries) + if len(normal_updates) >= 20: + with transaction.atomic(): + LoginHistory.objects.bulk_update( + normal_updates, + ['country', 'city', 'location_method'] + ) + logger.info(f"Updated {len(normal_updates)} normal coordinate records") + normal_updates = [] + except Exception as e: + logger.error(f"Error processing normal record {login.id}: {e}") + continue + + # Final batch update for remaining normal records + if normal_updates: + with transaction.atomic(): + LoginHistory.objects.bulk_update( + normal_updates, + ['country', 'city', 'location_method'] + ) + logger.info(f"Updated final {len(normal_updates)} normal coordinate records") + + logger.info(f"Completed login history update. Processed {processed_normal} normal records.") + + +def update_login_history(): + """Backward compatibility wrapper""" + return update_login_history_optimized() + +def update_location_history_records_optimized(): + """ + Optimized version with batch processing and progress tracking. + Updates location history records with city and country information using GeoNames database. + Only processes records that have coordinates but no city/country information. + """ + from apps.account.models import LocationHistory + + logger.info("Starting optimized location history update...") + + # Find records that need updating (limit to manageable batch size) + records = LocationHistory.objects.filter( + Q(city__isnull=True) | Q(city='') | Q(country__isnull=True) | Q(country=''), + lat__isnull=False, + lon__isnull=False + )[:1000] # Process in batches of 1000 + + total_records = records.count() + logger.info(f"Found {total_records} location history records to update") + + if total_records == 0: + logger.info("No records to update") + return + + updated_count = 0 + batch_updates = [] + + for i, record in enumerate(records, 1): + try: + # Get location data based on coordinates + location_data = get_location_by_coordinates(record.lat, record.lon) + + if location_data and location_data['status'] == 'success': + record.city = location_data['city'] + record.country = location_data['countryCode'] + batch_updates.append(record) + updated_count += 1 + + # Progress logging every 50 records + if i % 50 == 0: + logger.info(f"Processed {i}/{total_records} records ({updated_count} updated)") + + # Batch update every 20 records + if len(batch_updates) >= 20: + with transaction.atomic(): + LocationHistory.objects.bulk_update( + batch_updates, + ['city', 'country'] + ) + logger.info(f"Bulk updated {len(batch_updates)} location history records") + batch_updates = [] + + except Exception as e: + logger.error(f"Error processing location history record {record.id}: {e}") + continue + + # Final batch update for remaining records + if batch_updates: + with transaction.atomic(): + LocationHistory.objects.bulk_update( + batch_updates, + ['city', 'country'] + ) + logger.info(f"Final bulk update of {len(batch_updates)} location history records") + + logger.info(f"Completed location history update. Updated {updated_count}/{total_records} records.") + + +def update_location_history_records(): + """Backward compatibility wrapper""" + return update_location_history_records_optimized() + +if __name__ == "__main__": + # Configure logging for script execution + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('geo_optimization.log'), + logging.StreamHandler() + ] + ) + + logger.info("Starting optimized geo location processing...") + start_time = time.time() + + try: + update_login_history() + update_location_history_records() + + total_time = time.time() - start_time + logger.info(f"Completed all geo location processing in {total_time:.2f} seconds") + + except Exception as e: + logger.error(f"Error in main execution: {e}") + raise diff --git a/config/__init__.py b/config/__init__.py index a9ef1ea..b5a50bd 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -5,4 +5,4 @@ from __future__ import absolute_import, unicode_literals # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ('celery_app',) +__all__ = ('celery_app',) \ No newline at end of file diff --git a/config/enhanced_auth_middleware.py b/config/enhanced_auth_middleware.py new file mode 100644 index 0000000..b64a952 --- /dev/null +++ b/config/enhanced_auth_middleware.py @@ -0,0 +1,63 @@ +from rest_framework.authtoken.models import Token +from django.contrib.auth import get_user_model +from django.shortcuts import redirect +from django.urls import reverse +from django.contrib import messages + +User = get_user_model() + +def enhanced_auth_middleware(get_response): + """ + Enhanced middleware for API authentication with admin restriction + Handles custom documentation system authentication + """ + def middleware(request): + # Define protected paths that require staff access + protected_paths = ["/swagger", "/redoc", "/docs"] + is_protected_path = any(path in request.path for path in protected_paths) + + if is_protected_path: + # Check if user is authenticated and is staff + if request.user.is_authenticated and request.user.is_staff: + # Handle swagger token authentication from session + if 'swagger_token' in request.session: + token = request.session['swagger_token'] + # Validate the token still exists and is valid + try: + token_obj = Token.objects.get(key=token) + if token_obj.user.is_active: + request.META['HTTP_AUTHORIZATION'] = f"Token {token}" + else: + # Token user is inactive, clear session + del request.session['swagger_token'] + if 'swagger_user_info' in request.session: + del request.session['swagger_user_info'] + except Token.DoesNotExist: + # Token doesn't exist, clear session + del request.session['swagger_token'] + if 'swagger_user_info' in request.session: + del request.session['swagger_user_info'] + + # If no swagger token in session, provide default admin token for basic access + elif not request.META.get('HTTP_AUTHORIZATION'): + # Create or get token for the current admin user + token, _ = Token.objects.get_or_create(user=request.user) + request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" + + else: + # User is not authenticated or not staff + # For swagger-auth paths, allow access (they handle their own auth) + if '/swagger-auth/' not in request.path: + # Redirect to admin login for other protected paths + messages.warning(request, 'You must be logged in as a staff member to access API documentation.') + return redirect(f"{reverse('admin:login')}?next={request.path}") + + # For non-protected API paths, handle normal authentication + elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: + if request.user.is_authenticated and request.user.is_staff: + token, _ = Token.objects.get_or_create(user=request.user) + request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" + + return get_response(request) + + return middleware diff --git a/config/settings/base.py b/config/settings/base.py index 1aa2e08..883c813 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -11,11 +11,12 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ import os from pathlib import Path +from django.templatetags.static import static +from django.urls import reverse_lazy import environ from django.utils.translation import gettext_lazy as _ - env = environ.Env( # set casting, default value # DEBUG=(bool, False) @@ -50,6 +51,11 @@ LOCAL_APPS = [ 'apps.hadis.apps.HadisConfig', 'apps.library.apps.LibraryConfig', 'apps.video.apps.VideoConfig', + 'apps.podcast.apps.PodcastConfig', + 'apps.bookmark.apps.BookmarkConfig', + 'apps.article.apps.ArticleConfig', + 'apps.dobodbi_calendar.apps.DobodbiCalendarConfig', + 'apps.blog.apps.BlogConfig', 'dynamic_preferences', ] @@ -58,6 +64,7 @@ THIRD_PARTY_APPS = [ 'rest_framework', 'rest_framework.authtoken', 'drf_yasg', + 'rosetta', 'easy_thumbnails', 'phonenumber_field', 'dj_language', @@ -69,16 +76,25 @@ THIRD_PARTY_APPS = [ ] INSTALLED_APPS = [ - 'limitless_dashboard.apps.DashboardConfig', - # 'django.contrib.admin', + "unfold", + "unfold.contrib.filters", + "unfold.contrib.import_export", + "unfold.contrib.guardian", + "unfold.contrib.simple_history", + "unfold.contrib.forms", + "unfold.contrib.inlines", + "whitenoise.runserver_nostatic", + # 'limitless_dashboard.apps.DashboardConfig', + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.humanize', # Added for humanize template tags *THIRD_PARTY_APPS, *LOCAL_APPS, - + ] AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', # این خط را نگه دارید تا احراز هویت پیش‌فرض کار کند @@ -97,21 +113,22 @@ PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' AUTH_USER_MODEL = "account.User" - MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + "whitenoise.middleware.WhiteNoiseMiddleware", 'django.contrib.sessions.middleware.SessionMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + # "django.contrib.auth.middleware.LoginRequiredMiddleware", 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'config.language_code_middleware.language_middleware', - 'config.test_auth_middleware.test_auth_middleware', + 'config.enhanced_auth_middleware.enhanced_auth_middleware', + 'apps.account.middleware.admin_access.AdminAccessMiddleware', ] - ROOT_URLCONF = 'config.urls' TEMPLATES = [ @@ -128,7 +145,7 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'django.template.context_processors.i18n', - + "utils.admin.variables", ], }, }, @@ -141,7 +158,6 @@ WSGI_APPLICATION = 'config.wsgi.application' RECAPTCHA_PUBLIC_KEY = env('captcha_public_key') RECAPTCHA_PRIVATE_KEY = env('captcha_private_key') -# custom settings APPS_REORDER = { 'auth': { 'icon': 'icon-shield-check', @@ -150,7 +166,6 @@ APPS_REORDER = { 'account': { # 'icon': 'icon-', 'name': 'account' - } } # Database @@ -180,42 +195,15 @@ THUMBNAIL_ALIASES = { }, } -LANGUAGES_MAP = { - 'az': ['az', 'tr', 'fa', 'ar'], - 'tr': ['tr', 'az', 'fa', 'ar'], - 'ru': ['ru', 'az', 'tr', 'fa', 'ar'], - 'ar': ['ar', 'fa'], - 'ur': ['ur', 'en', 'fa', 'ar'], - 'en': ['en', 'ur', 'fa', 'ar'], - 'de': ['de', 'en', 'fr', 'es', 'ar'], - 'fa': ['fa', 'az', 'ar', 'en', 'ur'], - - 'fr': ['fr', 'en', 'ar', 'fa'], - 'es': ['es', 'en', 'ar', 'fa'], - 'id': ['id', 'en', 'ar', 'fa'], - 'sw': ['sw', 'en', 'ar', 'fa'], -} LANGUAGES = [ - ('ar', _('Arabic')), - ('az', _('Azerbaijani')), - ('fr', _('French')), - ('in', _('Indonesia')), - ('fa', _('Persian')), - ('ru', _('Russia')), - ('es', _('Spanish')), - ('sw', _('Swahili')), - ('tr', _('Turkish')), - ('de', _('German')), ('en', _('English')), ('fa', _('Persian')), - ('ur', _('Urdu')), - ('zh', _('Mandarin')), - ('zh', _('Chinese')), - ('he', _('Hebrew')), - ('he', _('Hebrew')), - ('bn', _('Bengali')), + ('ru', _('Russia')), +] +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), ] CELERY_BROKER_URL = env("REDIS_URL") @@ -243,7 +231,7 @@ AUTH_PASSWORD_VALIDATORS = [ REST_FRAMEWORK = { - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'DEFAULT_PAGINATION_CLASS': 'utils.pagination.StandardResultsSetPagination', 'PAGE_SIZE': 16, # 'DEFAULT_AUTHENTICATION_CLASSES': [ # 'apps.account.auth_back.TokenAuthentication2', @@ -275,8 +263,10 @@ STATIC_URL = '/static/' MEDIA_URL = '/media/' STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] -STATIC_ROOT = os.path.join(BASE_DIR, 'static', 'static') -MEDIA_ROOT = os.path.join(BASE_DIR, 'static', 'media') +# ********************************************************* +STATIC_ROOT = BASE_DIR/'staticfiles' +MEDIA_ROOT = BASE_DIR/'media' +# os.path.join(BASE_DIR, 'static', 'media') # FILER_ADMIN_ICON_SIZES = ('32', '48') @@ -284,18 +274,15 @@ FILER_ENABLE_LOGGING = True FILER_DEBUG = True ADMIN_TITLE = 'Imam Javad App' ADMIN_INDEX_TITLE = 'Imam Javad Administration' +SITE_DOMAIN = "https://imamjavad.nwhco.ir" +ONLINE_CLASS_FRONTEND_DOMAIN = env('ONLINE_CLASS_FRONTEND_DOMAIN', default=SITE_DOMAIN) +ONLINE_CLASS_TOKEN_TTL = env.int('ONLINE_CLASS_TOKEN_TTL', default=3000) +PLUGNMEET_SERVER_URL = env('PLUGNMEET_SERVER_URL', default='https://meet.newhorizonco.uk') +PLUGNMEET_API_KEY = env('PLUGNMEET_API_KEY', default='habibmeet_api_key_2024') +PLUGNMEET_API_SECRET = env('PLUGNMEET_API_SECRET', default='habibmeet_secret_zumyyYWqv7KR2kUqvYdq4z4sXg7XTBD2ljT6_2024') +PLUGNMEET_TIMEOUT = env.float('PLUGNMEET_TIMEOUT', default=10.0) -# Dictionary with phone number ranges and corresponding countries -# If a country is in this dictionary, it indicates that the project's OTP service supports that country -SERVICE_OTP_COUNTRU_API_KEY = { - "Iran": "https://console.melipayamak.com/api/send/simple/33213d78f1234e99b81f94eefda77e45" -} -SERVICE_OTP_COUNTRY_PHONE_RANGE = { - "98": "Iran", - "+98": "Iran" -} - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ @@ -304,16 +291,701 @@ SERVICE_OTP_COUNTRY_PHONE_RANGE = { # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -DEFAULT_SHOW_CITY_GUIDE_CITY = 'mashhad' FILE_UPLOAD_HANDLERS = [ 'django.core.files.uploadhandler.TemporaryFileUploadHandler', ] -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.gmail.com' -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = 'aliabdolahi.171@gmail.com' -EMAIL_HOST_PASSWORD = 'rkxb nnhx iave fxxt' \ No newline at end of file +###################################################################### +# Sessions +###################################################################### +SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" +LOGIN_URL = "admin:login" +LOGIN_REDIRECT_URL = reverse_lazy("home") +# STORAGES = { +# "default": { +# "BACKEND": "django.core.files.storage.FileSystemStorage", +# }, +# "staticfiles": { +# "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", +# }, +# } +###################################################################### +# Unfold +###################################################################### +from utils.admin import admin_url_generator , is_dovoodi_panel , is_main_panel + +UNFOLD = { + # "SITE_TITLE": _("Imam Jawad Admin"), + # "SITE_HEADER": _("Imam Jawad Admin"), + # "SITE_SUBHEADER": _("Imam Jawad Online School"), + "SITE_DROPDOWN": [ + { + "icon": "diamond", + "title": _("Imam Javad Site"), + "link": "https://habibapp.com", + }, + ], + "SITE_SYMBOL": "settings", + "SHOW_HISTORY": True, + "SHOW_LANGUAGES": True, + "ENVIRONMENT": "utils.environment_callback", + "DASHBOARD_CALLBACK": "utils.admin.dashboard_callback", + "SITE_ICON": { + "light": lambda request: static("images/logo1.svg"), # light mode + "dark": lambda request: static("images/logo1.svg"), # dark mode + }, + "SITE_SYMBOL": "speed", + "SHOW_BACK_BUTTON": True, # show/hide "Back" button on changeform in header, default: False + "THEME": "dark", + "LOGIN": { + "image": lambda request: static("images/image1.jpg"), + }, + # ✅ COLORS حذف شد - هر AdminSite رنگ‌های خودش را در utils/admin.py تعریف می‌کند + # - FormulaAdminSite: پالت سبز برای امام جواد + # - DovoodiAdminSite: پالت آبی-تیره برای داوودی (مطابق فرانت) + "STYLES": [ + # lambda request: static("css/styles.css"), + ], + "SCRIPTS": [ + # lambda request: static("js/chart.min.js"), + ], + "TABS": [ + { + "page": "video", + "models": ["video.videocollection", "video.pinnedvideocollection", 'video.middlevideocollection',], + "items": [ + { + "title": _("Collections"), + "icon": "collections_bookmark", + "link": lambda request: admin_url_generator(request, "video_pinnedvideocollection_changelist"), + "active": lambda request: "video/pinnedvideocollection" in request.path and "library/middlevideocollection" not in request.path, + }, + { + "title": _("Middle Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "video_middlevideocollection_changelist"), + "active": lambda request: "video/middlevideocollection" in request.path, + }, + ], + }, + { + "page": "library", + "models": ["library.bookcollection", "library.pinnedbookcollection", 'library.middlebookcollection'], + "items": [ + { + "title": _("Collections"), + "icon": "collections_bookmark", + "link": lambda request: admin_url_generator(request, "library_pinnedbookcollection_changelist"), + "active": lambda request: "library/pinnedbookcollection" in request.path and "library/middlebookcollection" not in request.path, + }, + { + "title": _("Middle Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "library_middlebookcollection_changelist"), + "active": lambda request: "library/middlebookcollection" in request.path, + + }, + ], + }, + { + "page": "article", + "models": ["article.articlecollection", "article.pinnedarticlecollection", "article.middlearticlecollection"], + "items": [ + { + "title": _("Pinned Collections"), + "icon": "collections_bookmark", + "link": lambda request: admin_url_generator(request, "article_pinnedarticlecollection_changelist"), + "active": lambda request: "article/pinnedarticlecollection" in request.path and "article/middlearticlecollection" not in request.path, + }, + { + "title": _("Regular Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "article_middlearticlecollection_changelist"), + "active": lambda request: "article/middlearticlecollection" in request.path, + }, + ], + }, + { + "page": "accounts", + "models": ["account.user", 'auth.group'], + "items": [ + { + "title": _("Users"), + "icon": "sports_motorsports", + "link": lambda request: admin_url_generator(request, "account_user_changelist"), + # "active": lambda request: request.path + # == lambda request: admin_url_generator(request, "account_user_changelist") + # and "email__isnull" not in request.GET, + }, + { + "title": _("Guest Users"), + "icon": "sports_motorsports", + "link": lambda request: f"{reverse_lazy('admin:account_user_changelist')}?email__isnull=true", + }, + ], + }, + { + "page": "authentication", + "models": ["auth.group", "auth.permission"], + "permission": lambda request: request.user.is_staff, + "items": [ + { + "title": _("Groups"), + "icon": "shield", + "link": lambda request: admin_url_generator(request, "auth_group_changelist"), + }, + ], + }, + { + "page": "courses", + "models": [ + "course.course", + "course.courselesson", + "course.courseglossary", + "course.courseattachment", + ], + "items": [ + { + "title": _("Courses"), + "icon": "school", + "link": lambda request: admin_url_generator(request, "course_course_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_course_changelist"))), + }, + { + "title": _("Course Lessons"), + "icon": "menu_book", + "link": lambda request: admin_url_generator(request, "course_courselesson_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_courselesson_changelist"))), + }, + { + "title": _("Course Attachments"), + "icon": "attach_file", + "link": lambda request: admin_url_generator(request, "course_courseattachment_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_courseattachment_changelist"))), + }, + { + "title": _("Course Glossary"), + "icon": "book", + "link": lambda request: admin_url_generator(request, "course_courseglossary_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_courseglossary_changelist"))), + }, + + ], + }, + { + "page": "course_online", + "models": [ + "course.courselivesession", + "course.livesessionuser", + "course.livesessionrecording", + ], + "items": [ + { + "title": _("Course Onlines"), + "icon": "video_call", + "link": lambda request: admin_url_generator(request, "course_courselivesession_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_courselivesession_changelist"))), + }, + { + "title": _("Session Users"), + "icon": "groups", + "link": lambda request: admin_url_generator(request, "course_livesessionuser_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_livesessionuser_changelist"))), + }, + { + "title": _("Session Recordings"), + "icon": "play_circle", + "link": lambda request: admin_url_generator(request, "course_livesessionrecording_changelist"), + "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_livesessionrecording_changelist"))), + }, + ], + }, + { + "page": "podcast", + "models": ["podcast.podcastcollection", "podcast.pinnedpodcastcollection", "podcast.middlepodcastcollection"], + "items": [ + { + "title": _("Pinned Collections"), + "icon": "collections_bookmark", + "link": lambda request: admin_url_generator(request, "podcast_pinnedpodcastcollection_changelist"), + "active": lambda request: "podcast/pinnedpodcastcollection" in request.path and "podcast/middlepodcastcollection" not in request.path, + }, + { + "title": _("Regular Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "podcast_middlepodcastcollection_changelist"), + "active": lambda request: "podcast/middlepodcastcollection" in request.path, + }, + ], + }, + ], + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + "navigation": [ + { + "title": _(""), + "separator": True, + "collapsible": True, + "items": [ + { + "title": _("Dashboard"), + "icon": "dashboard", + "link": lambda request: admin_url_generator(request, "index"), + }, + ], + }, + { + "title": _(""), + "items": [ + { + "title": _("Authentication"), + "icon": "shield", + "link": lambda request: admin_url_generator(request, "auth_group_changelist"), + "permission": lambda request: request.user.is_staff, + }, + ], + }, + { + "title": _(""), + "items": [ + { + "title": _("Users"), + "icon": "person", + "link": lambda request: admin_url_generator(request, "account_user_changelist"), + "permission": lambda request: request.user.is_staff, + }, + ], + }, + { + "title": _(""), + "items": [ + { + "title": _("Students"), + "icon": "school", + "link": lambda request: admin_url_generator(request, "account_studentuser_changelist"), + "permission": is_main_panel, + }, + + ] + }, + { + "title": _(""), + "items": [ + { + "title": _("Professors"), + "icon": "person_book", + "link": lambda request: admin_url_generator(request, "account_professoruser_changelist"), + "permission": is_main_panel, + }, + + ] + }, + { + "title": _(""), + "items": [ + { + "title": _("Calender"), + "icon": "calendar_today", + "link": lambda request: admin_url_generator(request, "dobodbi_calendar_calendaroccasions_changelist"), + "permission": is_dovoodi_panel, + }, + ], + }, + { + "title": _("Courses"), + "collapsible": True, + "separator": True, + "permission":is_main_panel, + "items": [ + { + "title": _("Categories"), + "icon": "category", + "link": lambda request: admin_url_generator(request, "course_coursecategory_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Courses"), + "icon": "school", + "link": lambda request: admin_url_generator(request, "course_course_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Lessons"), + "icon": "menu_book", + "link": lambda request: admin_url_generator(request, "course_lesson_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Attachments"), + "icon": "attach_file", + "link": lambda request: admin_url_generator(request, "course_attachment_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Glossary"), + "icon": "book", + "link": lambda request: admin_url_generator(request, "course_glossary_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Live Sessions"), + "icon": "video_call", + "link": lambda request: admin_url_generator(request, "course_courselivesession_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Session Users"), + "icon": "groups", + "link": lambda request: admin_url_generator(request, "course_livesessionuser_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Session Recordings"), + "icon": "play_circle", + "link": lambda request: admin_url_generator(request, "course_livesessionrecording_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Certificates"), + "icon": "workspace_premium", + "link": lambda request: admin_url_generator(request, "certificate_certificate_changelist"), + "permission":is_main_panel, + }, + ] + }, + { + "title": _("Quizzes"), + "collapsible": True, + "separator": True, + "permission":is_main_panel, + "items": [ + { + "title": _("Quizzes"), + "icon": "quiz", + "link": lambda request: admin_url_generator(request, "quiz_quiz_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Quiz Participants"), + "icon": "group", + "link": lambda request: admin_url_generator(request, "quiz_quizparticipant_changelist"), + "permission":is_main_panel, + }, + ] + }, + { + "title": _("Transactions"), + "collapsible": True, + "separator": True, + "items": [ + { + "title": _("Transactions"), + "icon": "payments", + "link": lambda request: admin_url_generator(request, "transaction_transactionparticipant_changelist"), + "permission":is_main_panel, + }, + ] + }, + { + "title": _("Libraries"), + "collapsible": True, + "separator": True, + "permission":is_dovoodi_panel, + "items": [ + { + "title": _("Books"), + "icon": "menu_book", + "link": lambda request: admin_url_generator(request, "library_book_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Categories"), + "icon": "category", + "link": lambda request: admin_url_generator(request, "library_category_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "library_pinnedbookcollection_changelist"), + "permission":is_dovoodi_panel, + }, + ] + }, + { + "title": _("Videos"), + "collapsible": True, + "separator": True, + "permission":is_dovoodi_panel, + "items": [ + { + "title": _("Videos"), + "icon": "live_tv", + "link": lambda request: admin_url_generator(request, "video_video_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Categories"), + "icon": "category", + "link": lambda request: admin_url_generator(request, "video_videocategory_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "video_pinnedvideocollection_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Playlists"), + "icon": "playlist_play", + "link": lambda request: admin_url_generator(request, "video_videoplaylist_changelist"), + "permission":is_dovoodi_panel, + # "active": lambda request: "video/videoplaylist" in request.path, + }, + + ] + }, + { + "title": _("Blog"), + "collapsible": True, + "separator": True, + "permission":is_main_panel, + "items": [ + { + "title": _("Comments"), + "icon": "comment", + "link": lambda request: admin_url_generator(request, "api_comment_changelist"), + "permission":is_main_panel, + }, + { + "title": _("Blogs"), + "icon": "article", + "link": lambda request: admin_url_generator(request, "blog_blog_changelist"), + "permission":is_main_panel, + }, + ] + }, + { + "title": _(""), + "items": [ + { + "title": _("App Versions"), + "icon": "system_update", + "link": lambda request: admin_url_generator(request, "api_appversion_changelist"), + }, + ], + }, + { + "title": _("Articles"), + "collapsible": True, + "separator": True, + "permission":is_dovoodi_panel, + "items": [ + { + "title": _("Articles"), + "icon": "article", + "link": lambda request: admin_url_generator(request, "article_article_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Categories"), + "icon": "category", + "link": lambda request: admin_url_generator(request, "article_articlecategory_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Pinned Collections"), + "icon": "collections_bookmark", + "link": lambda request: admin_url_generator(request, "article_pinnedarticlecollection_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Regular Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "article_middlearticlecollection_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Article Contents"), + "icon": "text_snippet", + "link": lambda request: admin_url_generator(request, "article_articlecontent_changelist"), + "permission":is_dovoodi_panel, + }, + ] + }, + { + "title": _("Podcasts"), + "collapsible": True, + "separator": True, + "permission":is_dovoodi_panel, + "items": [ + { + "title": _("Podcasts"), + "icon": "headset", + "link": lambda request: admin_url_generator(request, "podcast_podcast_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Categories"), + "icon": "category", + "link": lambda request: admin_url_generator(request, "podcast_podcastcategory_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Pinned Collections"), + "icon": "collections_bookmark", + "link": lambda request: admin_url_generator(request, "podcast_pinnedpodcastcollection_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Regular Collections"), + "icon": "view_module", + "link": lambda request: admin_url_generator(request, "podcast_middlepodcastcollection_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Playlists"), + "icon": "playlist_play", + "link": lambda request: admin_url_generator(request, "podcast_podcastplaylist_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("User Playlists"), + "icon": "person_add", + "link": lambda request: admin_url_generator(request, "podcast_userplaylist_changelist"), + "permission":is_dovoodi_panel, + }, + ] + }, + { + "title": _("Chats"), + "collapsible": True, + "separator": True, + "permission":is_main_panel, + "items": [ + { + "title": _("Chat Rooms"), + "icon": "forum", + "link": lambda request: admin_url_generator(request, "chat_roommessage_changelist"), + "permission":is_main_panel, + }, + # { + # "title": _("Chat Messages"), + # "icon": "chat", + # "link": lambda request: admin_url_generator(request, "apps_chat_chatmessage_changelist"), + # }, + # { + # "title": _("Read Status"), + # "icon": "mark_chat_read", + # "link": lambda request: admin_url_generator(request, "apps_chat_messagereadstatus_changelist"), + # }, + ] + }, + { + "title": _("Hadis"), + "collapsible": True, + "separator": True, + "permission":is_dovoodi_panel, + "items": [ + { + "title": _("Hadis Sects"), + "icon": "account_tree", + "link": lambda request: admin_url_generator(request, "hadis_hadissect_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Hadis Categories"), + "icon": "category", + "link": lambda request: admin_url_generator(request, "hadis_hadiscategory_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Hadis"), + "icon": "format_quote", + "link": lambda request: admin_url_generator(request, "hadis_hadis_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Hadis References"), + "icon": "link", + "link": lambda request: admin_url_generator(request, "hadis_hadisreference_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Hadis Tags"), + "icon": "label", + "link": lambda request: admin_url_generator(request, "hadis_hadistag_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Hadis Status"), + "icon": "flag", + "link": lambda request: admin_url_generator(request, "hadis_hadisstatus_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Transmitters"), + "icon": "person", + "link": lambda request: admin_url_generator(request, "hadis_transmitters_changelist"), + "permission":is_dovoodi_panel, + }, + { + "title": _("Hadis Transmitters"), + "icon": "group", + "link": lambda request: admin_url_generator(request, "hadis_hadistransmitter_changelist"), + "permission":is_dovoodi_panel, + }, + ] + }, + { + "title": "", + "items": [ + { + "title": _("Global Preferences"), + "icon": "settings", + "link": lambda request: admin_url_generator(request, "dynamic_preferences_globalpreferencemodel_changelist"), + }, + # You can add more preference sections here + ], + }, + # "STYLES": [ + # lambda request: static("css/styles.css"), + # ], + # "SCRIPTS": [ + # lambda request: static("js/scripts.js"), + # ], + ], + }, +} +UNFOLD_STUDIO_DEFAULT_FRAGMENT = "color-schemes" +UNFOLD_STUDIO_PERMISSION = lambda request: request.user.is_authenticated + +PLAUSIBLE_DOMAIN = env("PLAUSIBLE_DOMAIN") + +# uncomment it just to check if redis caches and signals works fine locally + +# CACHES = { +# 'default': { +# "BACKEND": "django_redis.cache.RedisCache", +# "LOCATION": "redis://127.0.0.1:6379/1", +# "OPTIONS": { +# "CLIENT_CLASS": "django_redis.client.DefaultClient", +# } +# }, +# 'memory': { +# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', +# 'LOCATION': 'unique-snowflake', +# 'TIMEOUT': 5000, +# }, +# } + + +CSRF_TRUSTED_ORIGINS = [ + "https://imamjavad.nwhco.ir", + "https://dovodi.newhorizonco.uk", + "https://dovoodi.newhorizonco.uk", +] \ No newline at end of file diff --git a/config/settings/develop.py b/config/settings/develop.py index e347d54..b41e666 100644 --- a/config/settings/develop.py +++ b/config/settings/develop.py @@ -5,6 +5,13 @@ DEBUG = True CORS_ALLOW_ALL_ORIGINS = True +# Explicitly enable Unfold Studio in development mode +UNFOLD_STUDIO_ENABLE_SAVE = True +UNFOLD_STUDIO_ENABLE_FILEUPLOAD = True +UNFOLD_STUDIO_ALWAYS_OPEN = True +# Allow all authenticated users to access the studio in development mode +UNFOLD_STUDIO_PERMISSION = lambda request: request.user.is_authenticated + # CACHES = { # 'default': { # "BACKEND": "django.core.cache.backends.dummy.DummyCache", diff --git a/config/settings/production.py b/config/settings/production.py index bf90107..698f447 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -67,9 +67,28 @@ CACHES = { # profiles_sample_rate=1.0, # ) -REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [ +# REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [ +# 'rest_framework.renderers.JSONRenderer', +# ] + + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'utils.pagination.StandardResultsSetPagination', + 'PAGE_SIZE': 16, + # 'DEFAULT_AUTHENTICATION_CLASSES': [ + # 'apps.account.auth_back.TokenAuthentication2', + # ], + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + # 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', + 'DEFAULT_RENDERER_CLASSES':[ 'rest_framework.renderers.JSONRenderer', ] +} LOGGING = { diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..34653b3 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,12 @@ +from .base import * # noqa + +DATABASES['default'] = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', +} + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +MEDIA_ROOT = BASE_DIR / 'test_media' diff --git a/config/test_auth_middleware.py b/config/test_auth_middleware.py index c1bbfd4..583f2cf 100644 --- a/config/test_auth_middleware.py +++ b/config/test_auth_middleware.py @@ -16,14 +16,14 @@ def test_auth_middleware(get_response): request.META['HTTP_AUTHORIZATION'] = "Token " + token.key - if "/swagger" in request.path or "/redoc" in request.path: - if not request.META.get('HTTP_AUTHORIZATION'): - user = User.objects.filter(is_staff=True, email="admin@gmail.com").first() - if user: - t, _ = Token.objects.get_or_create(user=user) - request.META['HTTP_AUTHORIZATION'] = f"Token {t}" - - # user = User.objects.filter(email="mortezaei2324@gmail.com").first() + # if "/swagger" in request.path or "/redoc" in request.path: + # if not request.META.get('HTTP_AUTHORIZATION'): + # user = User.objects.filter(is_staff=True, email="admin@gmail.com").first() + # if user: + # t, _ = Token.objects.get_or_create(user=user) + # request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + + # user = User.objects.filter(email="muhammadamin.ghorbani@gmail.com").first() # if user: # t, _ = Token.objects.get_or_create(user=user) # request.META['HTTP_AUTHORIZATION'] = f"Token {t}" diff --git a/config/urls.py b/config/urls.py index 713c339..e78de5c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -15,12 +15,12 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path, include +from django.urls import path, include, re_path from django.conf import settings from django.conf.urls.static import static from django.conf.urls.i18n import i18n_patterns -from utils import UploadTmpMedia -from django.conf.urls import url +from django.contrib.admin.views.decorators import staff_member_required +from utils import UploadTmpMedia, UploadChatMedia from django.http import JsonResponse from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt @@ -29,6 +29,43 @@ from rest_framework.response import Response from utils import absolute_url +from utils.admin import project_admin_site, HomeView ,dovoodi_admin_site + +from drf_yasg.views import get_schema_view +from drf_yasg import openapi +from rest_framework import permissions +import requests +from filer import views + +# Import custom API views +from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth +from apps.api.permissions import IsAdminOrSwaggerToken +from apps.api.decorators import swagger_auth_required + +# Restricted schema view for admin users and authenticated swagger users +schema_view = get_schema_view( + openapi.Info( + title="Imam Javad API", + default_version='v1', + description="Comprehensive API documentation for the Imam Javad educational platform", + contact=openapi.Contact(email="contact@imamjavad.com"), + license=openapi.License(name="MIT License"), + ), + public=False, + permission_classes=(IsAdminOrSwaggerToken,), +) + + +def oneapi_translate(request): + dist_lang = request.GET.get('dist_lang') + q = request.GET.get('q') + url = f"https://one-api.ir/translate/?token=169700:6485a38c34b00&action=google&lang={dist_lang}&q={q}" + try: + data = requests.get(url).json() + except Exception as e: + data = {} + + return JsonResponse(data) api_patterns = [ @@ -41,24 +78,59 @@ api_patterns = [ path('certificates/', include('apps.certificate.urls')), path('hadis/', include('apps.hadis.urls')), path('library/', include('apps.library.urls')), - path('videos/', include('apps.video.urls')), + path('article/', include('apps.article.urls')), + path('podcast/', include('apps.podcast.urls')), + path('bookmarks/', include('apps.bookmark.urls')), + path('calendar/', include('apps.dobodbi_calendar.urls')), + path('blog/', include('apps.blog.urls')), path('settings/', include('dynamic_preferences.urls')), - path('upload-tmp-media/', UploadTmpMedia.as_view()), + path('upload-chat-media/', UploadChatMedia.as_view()), # دائمی در /media/chat/ + path('upload-tmp-media/', UploadTmpMedia.as_view()), # موقت در /static/tmp/ ] urlpatterns = [ + path("admin/", HomeView.as_view(), name="home"), + path("i18n/", include("django.conf.urls.i18n")), + # path('admin/', admin.site.urls), path('api/', include(api_patterns)), # path('test/', include('apps.api.urls')) + path('oneapi-translation/', oneapi_translate), + path('admin/filer/', include('filer.urls')), + path('filer/', include('filer.urls')), + +] +# Protected swagger URL patterns +swagger_urlpatterns = [ + path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'), + path('swagger-auth/clear/', clear_swagger_auth, name='clear-swagger-auth'), + re_path(r'^swagger(?P\.json|\.yaml)$', + swagger_auth_required(schema_view.without_ui(cache_timeout=0)), + name='schema-json'), + path('swagger/', CustomSwaggerView.as_view(), name='schema-swagger-ui'), + re_path(r'^redoc/$', + swagger_auth_required(schema_view.with_ui('redoc', cache_timeout=0)), + name='schema-redoc'), ] -urlpatterns += i18n_patterns( - path('', include('limitless_dashboard.urls')), +urlpatterns+= i18n_patterns( + path("imam-javad/admin/", project_admin_site.urls), + path("dovoodi/admin/", dovoodi_admin_site.urls ), + path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), + *swagger_urlpatterns, + path('admin/filer/', include('filer.urls')), + path('filer/', include('filer.urls')), ) + if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + + + + diff --git a/create_live_room.sh b/create_live_room.sh new file mode 100755 index 0000000..7568f71 --- /dev/null +++ b/create_live_room.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -e + +API_BASE="https://imamjavad.newhorizonco.uk/api" +LIVE_BASE="https://live.newhorizonco.uk" + +DEFAULT_COURSE_SLUG="test-1-ghorbani" +DEFAULT_AUTH_TOKEN="e5ec00c7660302c3225276eaf7be99459c9a7012" + +AUTH_TOKEN="$DEFAULT_AUTH_TOKEN" +COURSE_SLUG="$DEFAULT_COURSE_SLUG" + +print_usage() { + echo "Usage: $0 [-t ] [-s ] [-h]" + echo "" + echo "Options:" + echo " -t User authentication token (default: test-1-ghorbani user token)" + echo " -s Course slug (default: test-1-ghorbani)" + echo " -h Show this help message" + echo "" + echo "Example:" + echo " $0" + echo " $0 -s my-course" + echo " $0 -t custom-token -s custom-course" +} + +while getopts "t:s:h" opt; do + case $opt in + t) AUTH_TOKEN="$OPTARG" ;; + s) COURSE_SLUG="$OPTARG" ;; + h) print_usage; exit 0 ;; + *) print_usage; exit 1 ;; + esac +done + +echo "✓ Using authentication token" +echo "" + +echo "Step 1: Creating live session room..." +ROOM_RESPONSE=$(curl -s -X POST "$API_BASE/courses/$COURSE_SLUG/online/room/create/" \ + -H "Content-Type: application/json" \ + -H "Authorization: Token $AUTH_TOKEN" \ + -d '{}') + +CREATED_ROOM_ID=$(echo "$ROOM_RESPONSE" | grep -o '"room_id":"[^"]*' | cut -d'"' -f4 | head -1) + +if [ -z "$CREATED_ROOM_ID" ]; then + echo "Error: Failed to create room" + echo "Response: $ROOM_RESPONSE" + exit 1 +fi + +echo "✓ Room created: $CREATED_ROOM_ID" +echo "" + +echo "Step 2: Getting join token..." +TOKEN_RESPONSE=$(curl -s -X POST "$API_BASE/courses/online/room/token/" \ + -H "Content-Type: application/json" \ + -H "Authorization: Token $AUTH_TOKEN" \ + -d "{\"course_slug\": \"$COURSE_SLUG\"}") + +JOIN_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"token":"[^"]*' | cut -d'"' -f4) + +if [ -z "$JOIN_TOKEN" ]; then + echo "Error: Failed to get join token" + echo "Response: $TOKEN_RESPONSE" + exit 1 +fi + +echo "✓ Join token generated" +echo "" + +FULL_URL="$LIVE_BASE/?access_token=$JOIN_TOKEN" + +echo "==========================================" +echo "Room created successfully!" +echo "==========================================" +echo "" +echo "Full Room Link:" +echo "$FULL_URL" +echo "" +echo "Room Details:" +echo " Room ID: $CREATED_ROOM_ID" +echo " Course: $COURSE_SLUG" +echo "==========================================" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fd8c018..72a0cbc 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,9 +7,12 @@ services: build: context: . dockerfile: Dockerfile.prod - command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=32 --timeout 560 + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=4 --timeout 560 volumes: - - static_volume:/usr/src/app/static + - staticfiles_volume:/usr/src/app/staticfiles + # /usr/src/app/static + - media_volume:/usr/src/app/media + - logs_volume:/usr/src/app/logs ports: - "8010:8000" env_file: @@ -51,7 +54,9 @@ services: command: celery -A config worker -l info volumes: - .:/usr/src/app/ - - static_volume:/usr/src/app/static + - staticfiles_volume:/usr/src/app/staticfiles + - media_volume:/usr/src/app/media + - logs_volume:/usr/src/app/logs depends_on: - imam-javad_redis @@ -68,6 +73,7 @@ services: command: celery -A config beat -l info volumes: - .:/usr/src/app/ + - logs_volume:/usr/src/app/logs depends_on: - imam-javad_redis networks: @@ -77,7 +83,10 @@ services: volumes: postgres_data: - static_volume: + staticfiles_volume: + media_volume: + # static_volume: + logs_volume: redis_data: networks: diff --git a/docs/CATEGORY_FILTER_EXAMPLES.md b/docs/CATEGORY_FILTER_EXAMPLES.md new file mode 100644 index 0000000..423e3d3 --- /dev/null +++ b/docs/CATEGORY_FILTER_EXAMPLES.md @@ -0,0 +1,77 @@ +# نمونه‌های فیلتر دسته‌بندی برای Article و Book + +## Article List API + +### فیلتر با یک دسته‌بندی: +```bash +curl -X GET "http://localhost:8000/api/article/list/?category=islamic-history" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با چند دسته‌بندی (جدا شده با کاما): +```bash +curl -X GET "http://localhost:8000/api/article/list/?category=islamic-history,quran-tafsir,hadith" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با دسته‌بندی و سایر پارامترها: +```bash +curl -X GET "http://localhost:8000/api/article/list/?category=islamic-history,quran-tafsir&sort=-view_count&search=امام" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +--- + +## Book List API + +### فیلتر با یک دسته‌بندی: +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با چند دسته‌بندی (جدا شده با کاما): +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh,aqaid,akhlaq" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با دسته‌بندی و سایر پارامترها: +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh,aqaid&sort=-download_count&search=احکام" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر دسته‌بندی + مجموعه + بوک‌مارک: +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh&collection_id=5&is_bookmark=true" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +--- + +## نکات مهم: + +1. **Slug دسته‌بندی**: باید از slug دسته‌بندی استفاده کنید (نه ID) +2. **جداکننده**: از کاما (`,`) برای جدا کردن چند دسته‌بندی استفاده کنید +3. **فاصله**: فاصله‌های قبل و بعد از کاما به صورت خودکار حذف می‌شوند +4. **نتیجه**: نتایج به صورت DISTINCT برگردانده می‌شوند (بدون تکرار) +5. **Logic**: فیلتر با منطق OR کار می‌کند (هر مقاله/کتابی که در یکی از دسته‌بندی‌های داده شده باشد) + +--- + +## پارامترهای قابل ترکیب: + +### برای Article: +- `category`: slug یک یا چند دسته‌بندی (جدا شده با کاما) +- `collection`: slug مجموعه +- `is_bookmark`: true/false +- `search`: جستجو در عنوان +- `sort`: مرتب‌سازی (created_at, -created_at, view_count, -view_count, title, -title) + +### برای Book: +- `category`: slug یک یا چند دسته‌بندی (جدا شده با کاما) +- `collection_id`: ID مجموعه +- `is_bookmark`: true/false +- `search`: جستجو در عنوان، خلاصه، ناشر، ISBN +- `sort`: مرتب‌سازی (created_at, -created_at, view_count, -view_count, download_count, -download_count, title, -title, pin, -pin) diff --git a/docs/CHANGELOG_EXCHANGE_TOKEN.md b/docs/CHANGELOG_EXCHANGE_TOKEN.md new file mode 100644 index 0000000..0406cab --- /dev/null +++ b/docs/CHANGELOG_EXCHANGE_TOKEN.md @@ -0,0 +1,140 @@ +# تغییرات API Exchange Token + +## نسخه جدید (اکتبر 2024) + +### تغییرات اساسی + +#### 1. انتقال API از `courses` به `account` +- **قدیمی**: `/api/courses/auth/exchange-token/` +- **جدید**: `/api/account/exchange-token/` + +#### 2. تغییر ساختار Response +**قدیمی:** +```json +{ + "success": true, + "message": "ورود موفق", + "user": { + "id": "123", + "email": "user@example.com", + "name": "علی احمدی", + "role": "admin", + "is_admin": true + } +} +``` + +**جدید:** +```json +{ + "success": true, + "message": "ورود موفق", + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } +} +``` + +#### 3. تغییر URL تولید Token موقت +**Response قبلی:** +```json +{ + "token": "abc123", + "url": "https://frontend.example.com/join-class?token=abc123", + "expires_in": 300 +} +``` + +**Response جدید:** +```json +{ + "token": "abc123xyz789", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789&slug=python-basics", + "expires_in": 300 +} +``` + +### تغییرات در فیلدها + +| فیلد قدیمی | فیلد جدید | توضیحات | +|-----------|----------|---------| +| `user.name` | `user.fullname` | تغییر نام فیلد | +| `user.id` (string) | `user.id` (number) | تغییر نوع داده | +| `user.role` | حذف شد | دیگر ارسال نمی‌شود | +| `user.is_admin` | حذف شد | دیگر ارسال نمی‌شود | +| - | `token` | **اضافه شد**: توکن احراز هویت DRF | +| - | `user.avatar` | **اضافه شد**: URL تصویر پروفایل | + +### URL ثابت + +دیگر از تنظیمات یا environment variable استفاده نمی‌شود. URL ثابت است: + +``` +https://imamjavad.newhorizonco.uk/join-class?token={TEMP_TOKEN}&slug={COURSE_SLUG} +``` + +### مزایای تغییرات + +✅ **Token واقعی**: دیگر نیازی به login مجدد نیست +✅ **Avatar**: نمایش تصویر پروفایل کاربر +✅ **Slug دوره**: دسترسی مستقیم به اطلاعات دوره +✅ **URL ثابت**: عدم وابستگی به تنظیمات +✅ **ساده‌تر**: حذف فیلدهای اضافی (role, is_admin) + +### Migration Guide + +#### Frontend (Next.js) + +**قدیمی:** +```typescript +const user = await api.exchangeToken(tempToken); +localStorage.setItem('user', JSON.stringify(user)); +``` + +**جدید:** +```typescript +const data = await exchangeToken(tempToken); +localStorage.setItem('authToken', data.token); // ⭐ جدید +localStorage.setItem('user', JSON.stringify(data.user)); +``` + +#### Flutter + +**قدیمی:** +```dart +final user = response['user']; +// نیاز به login مجدد داشت +``` + +**جدید:** +```dart +final authToken = response['token']; // ⭐ جدید +final user = response['user']; +// ذخیره token برای درخواست‌های بعدی +await storage.write(key: 'authToken', value: authToken); +``` + +### Breaking Changes ⚠️ + +1. **URL تغییر کرد**: باید از `/api/account/exchange-token/` استفاده شود +2. **فیلد `token` اجباری است**: باید در frontend ذخیره و استفاده شود +3. **فیلد `name` به `fullname` تغییر کرد** +4. **فیلدهای `role` و `is_admin` حذف شدند** +5. **URL موقت شامل `slug` می‌شود** + +### تاریخ اعمال تغییرات + +تاریخ: **14 اکتبر 2024** + +### فایل‌های تغییر یافته + +1. `apps/account/serializers/auth.py` (جدید) +2. `apps/account/views/auth.py` (جدید) +3. `apps/account/urls.py` (آپدیت) +4. `apps/course/views/course.py` (آپدیت: CourseOnlineClassTokenAPIView) +5. `docs/exchange_token_api.md` (آپدیت) +6. `docs/online_class_entry_flow.md` (آپدیت) diff --git a/docs/Custom_Swagger_API_Documentation_Implementation_Guide.md b/docs/Custom_Swagger_API_Documentation_Implementation_Guide.md new file mode 100644 index 0000000..c771eac --- /dev/null +++ b/docs/Custom_Swagger_API_Documentation_Implementation_Guide.md @@ -0,0 +1,1433 @@ +# Complete Prompt for Custom Swagger & API Documentation System Implementation + +## Overview +Implement a comprehensive custom API documentation system that replaces the default Swagger UI with a custom documentation interface, includes advanced authentication, and provides a beautiful user experience. The system should be restricted to admin users only. + +## Core Requirements + +### 1. Custom API Documentation System +- Create a custom API documentation view that overrides existing Swagger configuration +- Implement a collapsible sidebar navigation where each Django app is displayed as a main item +- When clicking on an app name, show/hide a list of API endpoints for that specific app underneath +- When clicking on a specific endpoint, scroll to that section and display detailed API documentation including descriptions and response examples +- Display response examples using a beautiful JSON editor/viewer with syntax highlighting +- All content must be in English +- The documentation should be responsive and work well on different screen sizes + +### 2. Custom Swagger UI with Authentication +- Override the existing Swagger setup with a custom implementation +- Create a custom Swagger UI template with authentication banner +- Implement token-based authentication system with 40-character Django tokens +- Add session management for storing authentication tokens +- Display user information and authentication status in a fixed header +- Provide token management interface (login, logout, change token) + +### 3. Security & Access Control +- Restrict access to documentation, Swagger UI, and ReDoc to admin users only +- Users must be logged into the Django admin panel to access any documentation +- Implement `@staff_member_required` decorators on all documentation views +- Update middleware to handle authentication for API endpoints +- Redirect unauthorized users to admin login page + +### 4. UI/UX Requirements +- Fixed header in Swagger UI that stays visible during scrolling +- Responsive design that works on mobile, tablet, and desktop +- Beautiful gradient backgrounds and modern styling +- Smooth animations and transitions +- Professional color scheme and typography +- Integration buttons between different documentation systems + +## Technical Implementation + +### File Structure to Create: + +``` +apps/api/views/ +├── __init__.py +├── documentation.py +├── swagger_views.py +└── api_views.py + +templates/ +├── api/ +│ └── documentation.html +├── swagger/ +│ ├── ui.html +│ └── auth.html +└── admin/ + └── login_required.html +``` + +### 1. Custom Documentation View (`apps/api/views/documentation.py`) + +```python +import json +from django.shortcuts import render +from django.views import View +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator + +@method_decorator(staff_member_required, name='dispatch') +class CustomAPIDocumentationView(View): + """ + Custom API Documentation view with collapsible sidebar navigation + Requires admin login to access + """ + + def get(self, request): + api_structure = self._get_api_structure() + context = { + 'api_structure': api_structure, + 'request': request, + 'title': 'Your Project API Documentation', + 'description': 'Comprehensive API documentation with interactive examples', + } + return render(request, 'api/documentation.html', context) + + def _get_api_structure(self): + """ + Define your API structure here with apps, endpoints, parameters, and response examples + """ + return { + 'app_name': { + 'name': 'App Display Name', + 'description': 'App description', + 'endpoints': [ + { + 'name': 'Endpoint Name', + 'method': 'GET', + 'url': '/api/endpoint/', + 'description': 'Endpoint description', + 'parameters': [ + {'name': 'param', 'type': 'string', 'description': 'Parameter description', 'required': True} + ], + 'response_examples': { + 'success': json.dumps({"example": "response"}, indent=2) + } + } + ] + } + } +``` + +### 2. Custom Swagger Views (`apps/api/views/swagger_views.py`) + +```python +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 rest_framework.authtoken.models import Token + +@method_decorator(staff_member_required, name='dispatch') +class CustomSwaggerView(View): + """ + Custom Swagger UI view with authentication banner + Requires admin login to access + """ + def get(self, request): + context = { + 'swagger_spec_url': '/en/swagger.json', # Adjust based on your URL structure + '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') +``` + +### 3. Update URLs (`config/urls.py`) + +```python +from django.contrib.admin.views.decorators import staff_member_required +from drf_yasg.views import get_schema_view +from drf_yasg import openapi +from rest_framework import permissions +from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth + +# Restricted schema view +schema_view = get_schema_view( + openapi.Info( + title="Your Project API", + default_version='v1', + description="Your Project API Documentation", + contact=openapi.Contact(email="admin@yourproject.com"), + license=openapi.License(name="Your License"), + ), + public=False, + permission_classes=(permissions.IsAdminUser,), +) + +# Protected swagger URL patterns +swagger_urlpatterns = [ + path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'), + path('swagger-auth/clear/', clear_swagger_auth, name='clear-swagger-auth'), + re_path(r'^swagger(?P\.json|\.yaml)$', + staff_member_required(schema_view.without_ui(cache_timeout=0)), + name='schema-json'), + path('swagger/', CustomSwaggerView.as_view(), name='schema-swagger-ui'), + re_path(r'^redoc/$', + staff_member_required(schema_view.with_ui('redoc', cache_timeout=0)), + name='schema-redoc'), +] + +urlpatterns += i18n_patterns( + path("admin/", admin.site.urls), # Adjust based on your admin setup + path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), + *swagger_urlpatterns, +) +``` + +### 4. Enhanced Middleware (Update existing or create new) + +```python +from rest_framework.authtoken.models import Token +from django.contrib.auth import get_user_model + +User = get_user_model() + +def enhanced_auth_middleware(get_response): + """ + Enhanced middleware for API authentication with admin restriction + """ + def middleware(request): + protected_paths = ["/swagger", "/redoc", "/docs"] + is_protected_path = any(path in request.path for path in protected_paths) + + if is_protected_path: + if request.user.is_authenticated and request.user.is_staff: + # Provide API token for authenticated staff users + if 'swagger_token' in request.session: + token = request.session['swagger_token'] + request.META['HTTP_AUTHORIZATION'] = f"Token {token}" + elif not request.META.get('HTTP_AUTHORIZATION'): + # Fallback to default admin user token + admin_user = User.objects.filter(is_staff=True).first() + if admin_user: + token, _ = Token.objects.get_or_create(user=admin_user) + request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" + + return get_response(request) + + return middleware +``` + +### 5. Templates + +#### Custom Documentation Template (`templates/api/documentation.html`) +- Responsive sidebar with collapsible app sections +- Beautiful JSON viewer with Prism.js syntax highlighting +- Smooth scrolling and animations +- Links to Swagger UI and ReDoc +- Mobile-friendly design + +#### Custom Swagger UI Template (`templates/swagger/ui.html`) +- Fixed header with authentication banner +- User information display +- Token management buttons +- Links to custom documentation +- Responsive design with proper mobile handling +- Backdrop blur effects + +#### Authentication Template (`templates/swagger/auth.html`) +- Beautiful login form for token authentication +- User information display +- Token validation and management +- Help section and instructions + +### 6. Key Features to Implement + +#### Fixed Header in Swagger UI: +```css +.auth-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + backdrop-filter: blur(10px); +} + +body { + padding-top: 80px; /* Adjust based on header height */ +} + +@media (max-width: 768px) { + body { + padding-top: 100px; /* More space for mobile */ + } +} +``` + +#### Responsive Design: +- Mobile-first approach +- Collapsible navigation +- Touch-friendly buttons +- Proper spacing and typography + +#### Security Implementation: +- `@staff_member_required` decorators on all views +- Session-based token management +- Automatic redirect to admin login +- Protected schema endpoints + +### 7. Styling Guidelines + +#### Color Scheme: +- Primary: `#667eea` to `#764ba2` (gradient) +- Success: `#28a745` +- Warning: `#f39c12` +- Danger: `#e74c3c` +- Background: `#f8f9fa` + +#### Typography: +- Font Family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif +- Responsive font sizes +- Proper line heights and spacing + +#### Components: +- Rounded corners (6-12px border-radius) +- Subtle shadows and gradients +- Smooth transitions (0.3s ease) +- Hover effects and animations + +### 8. Integration Points + +#### With Existing Admin: +- Use existing admin authentication +- Maintain admin session +- Integrate with admin navigation if needed + +#### With API System: +- Discover endpoints automatically +- Generate documentation from views +- Include proper parameter documentation +- Provide realistic response examples + +### 9. Testing Checklist + +- [ ] Unauthorized users redirected to admin login +- [ ] Authorized staff users can access all documentation +- [ ] Fixed header works properly on all screen sizes +- [ ] Token authentication system functions correctly +- [ ] All links between documentation systems work +- [ ] Mobile responsiveness is maintained +- [ ] JSON syntax highlighting works +- [ ] Collapsible navigation functions properly + +### 10. Customization Notes + +- Replace "Your Project" with actual project name +- Update API structure in `_get_api_structure()` method +- Adjust URL patterns based on your project structure +- Customize color scheme and branding +- Add your specific API endpoints and documentation +- Update contact information and licensing + +## Template Implementation Details + +### Custom Documentation Template Structure (`templates/api/documentation.html`) + +This template should create a beautiful, responsive API documentation interface with the following structure and appearance: + +#### Visual Layout: +``` +┌─────────────────────────────────────────────────────────────┐ +│ Header with Title & Actions │ +├──────────────┬──────────────────────────────────────────────┤ +│ Sidebar │ Main Content │ +│ │ │ +│ ┌─ App 1 │ ┌─ Endpoint Documentation │ +│ │ └─ GET /api │ │ Method + URL │ +│ │ └─ POST / │ │ Description │ +│ │ │ │ Parameters Table │ +│ ┌─ App 2 │ │ Response Examples (JSON) │ +│ │ └─ GET / │ └─────────────────────────────────────────│ +│ │ └─ PUT / │ │ +│ │ ┌─ Next Endpoint Documentation │ +│ │ │ ... │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +#### Complete Template Implementation: + +```html + + + + + + {{ title }} + + + + + + + + + + + + +
+ + + + +
+
+
+
+

{{ title }}

+

{{ description }}

+
+ +
+
+ + + {% for app_key, app_data in api_structure.items %} + {% for endpoint in app_data.endpoints %} +
+
+

{{ endpoint.name }}

+ {{ endpoint.method }} + {{ endpoint.url }} +
+ +

{{ endpoint.description }}

+ + {% if endpoint.parameters %} +
+

+ + Parameters +

+ + + + + + + + + + + {% for param in endpoint.parameters %} + + + + + + + {% endfor %} + +
NameTypeRequiredDescription
{{ param.name }}{{ param.type }} + {% if param.required %} + Required + {% else %} + Optional + {% endif %} + {{ param.description }}
+
+ {% endif %} + +
+

+ + Response Examples +

+ +
+ {% for response_type, response_data in endpoint.response_examples.items %} + + {% endfor %} +
+ + {% for response_type, response_data in endpoint.response_examples.items %} +
+
{{ response_data|safe }}
+
+ {% endfor %} +
+
+ {% endfor %} + {% endfor %} +
+
+ + + + + + + + + +``` + +#### Additional CSS Styles for Complete Functionality + +Add these styles to complete the documentation template: + +```css +/* Endpoint Documentation Styles */ +.endpoint-section { + margin-bottom: 40px; + padding: 25px; + background: white; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.08); + border: 1px solid #e9ecef; +} + +.endpoint-header { + display: flex; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid #e9ecef; + flex-wrap: wrap; + gap: 15px; +} + +.endpoint-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--dark-bg); + margin: 0; +} + +.endpoint-url { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background: var(--light-bg); + padding: 8px 12px; + border-radius: 6px; + font-size: 0.9rem; + color: var(--dark-bg); + border: 1px solid #dee2e6; +} + +.endpoint-description { + color: #666; + margin-bottom: 25px; + line-height: 1.6; + font-size: 1rem; +} + +/* Parameters Section */ +.parameters-section { + margin-bottom: 25px; +} + +.section-title { + font-size: 1.2rem; + font-weight: 600; + color: var(--dark-bg); + margin-bottom: 15px; + display: flex; + align-items: center; +} + +.section-title i { + margin-right: 8px; + color: var(--secondary-color); +} + +.parameters-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); +} + +.parameters-table th, +.parameters-table td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #e9ecef; +} + +.parameters-table th { + background: var(--light-bg); + font-weight: 600; + color: var(--dark-bg); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.parameters-table tbody tr:hover { + background: #f8f9fa; +} + +.param-name { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-weight: 600; + color: var(--secondary-color); + background: #f8f9fa; + padding: 4px 8px; + border-radius: 4px; +} + +.param-type { + background: #e3f2fd; + color: #1976d2; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; +} + +.param-required { + background: #ffebee; + color: #c62828; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; +} + +/* Response Examples Section */ +.response-section { + margin-top: 25px; +} + +.response-tabs { + display: flex; + margin-bottom: 15px; + border-bottom: 1px solid #e9ecef; + gap: 5px; +} + +.response-tab { + padding: 10px 20px; + cursor: pointer; + border: none; + background: none; + color: #666; + font-weight: 500; + transition: all 0.3s ease; + border-radius: 6px 6px 0 0; + position: relative; +} + +.response-tab:hover { + background: #f8f9fa; + color: var(--secondary-color); +} + +.response-tab.active { + color: var(--secondary-color); + background: #f8f9fa; + border-bottom: 2px solid var(--secondary-color); +} + +.json-viewer { + background: #1e1e1e; + border-radius: 8px; + padding: 20px; + margin-top: 15px; + overflow-x: auto; + border: 1px solid #e9ecef; +} + +.json-viewer pre { + margin: 0; + color: #d4d4d4; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.9rem; + line-height: 1.5; +} + +/* Mobile Responsive Design */ +.mobile-toggle { + display: none; + position: fixed; + top: 20px; + left: 20px; + z-index: 1001; + background: var(--secondary-color); + color: white; + border: none; + padding: 10px; + border-radius: 6px; + cursor: pointer; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); +} + +@media (max-width: 768px) { + .mobile-toggle { + display: block; + } + + .sidebar { + transform: translateX(-100%); + transition: transform 0.3s ease; + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + .main-content { + margin-left: 0; + padding: 20px 15px; + } + + .endpoint-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .action-buttons { + flex-direction: column; + width: 100%; + } + + .btn-swagger, + .btn-redoc { + justify-content: center; + width: 100%; + } + + .parameters-table { + font-size: 0.8rem; + } + + .parameters-table th, + .parameters-table td { + padding: 8px 10px; + } + + .response-tabs { + flex-wrap: wrap; + } + + .response-tab { + padding: 8px 15px; + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .main-content { + padding: 15px 10px; + } + + .endpoint-section { + padding: 15px; + } + + .endpoint-title { + font-size: 1.2rem; + } + + .parameters-table { + display: block; + overflow-x: auto; + white-space: nowrap; + } +} + +/* Scrollbar Styling */ +.sidebar::-webkit-scrollbar { + width: 6px; +} + +.sidebar::-webkit-scrollbar-track { + background: rgba(255,255,255,0.1); +} + +.sidebar::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.3); + border-radius: 3px; +} + +.sidebar::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.5); +} + +/* Loading Animation */ +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid var(--secondary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Smooth Animations */ +* { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Focus States for Accessibility */ +.endpoint-item:focus, +.app-header:focus, +.response-tab:focus { + outline: 2px solid var(--secondary-color); + outline-offset: 2px; +} +``` + +#### Template Features & Appearance: + +##### 🎨 **Visual Design Elements:** + +1. **Color Scheme:** + - Primary: Dark blue-gray (`#2c3e50`) + - Secondary: Bright blue (`#3498db`) + - Success: Green (`#27ae60`) + - Warning: Orange (`#f39c12`) + - Danger: Red (`#e74c3c`) + +2. **Layout Structure:** + - **Fixed sidebar** (300px width) with dark gradient background + - **Main content area** with white background and proper spacing + - **Responsive design** that collapses sidebar on mobile + +3. **Typography:** + - Primary font: 'Segoe UI' family for readability + - Monospace font for code elements + - Proper font weights and sizes for hierarchy + +##### 📱 **Responsive Behavior:** + +1. **Desktop (>768px):** + - Fixed sidebar navigation + - Full-width main content + - Horizontal action buttons + +2. **Tablet (768px-480px):** + - Collapsible sidebar with overlay + - Mobile toggle button + - Stacked action buttons + +3. **Mobile (<480px):** + - Full-width content + - Compact spacing + - Horizontal scrolling for tables + +##### 🎯 **Interactive Elements:** + +1. **Sidebar Navigation:** + - Collapsible app sections with smooth animations + - Hover effects with color transitions + - Active state indicators + - Chevron icons that rotate on expand/collapse + +2. **Endpoint Documentation:** + - Smooth scrolling to sections + - Temporary highlight effect on navigation + - Tabbed response examples + - Syntax-highlighted JSON with Prism.js + +3. **Parameters Table:** + - Hover effects on rows + - Color-coded parameter types + - Required/Optional badges + - Responsive table design + +##### 🔧 **Functional Components:** + +1. **HTTP Method Badges:** + ```css + .method-get { background: #27ae60; } /* Green */ + .method-post { background: #3498db; } /* Blue */ + .method-put { background: #f39c12; } /* Orange */ + .method-delete { background: #e74c3c; } /* Red */ + .method-patch { background: #9b59b6; } /* Purple */ + ``` + +2. **JSON Response Viewer:** + - Dark theme for better code readability + - Syntax highlighting with Prism.js + - Proper indentation and formatting + - Copy-friendly monospace font + +3. **Navigation Features:** + - Smooth scroll behavior + - Active section highlighting + - Mobile-friendly touch targets + - Keyboard accessibility support + +##### 📋 **Content Structure:** + +Each endpoint section includes: + +1. **Header Section:** + - Endpoint name (large, bold) + - HTTP method badge + - URL path in code format + +2. **Description:** + - Clear, readable explanation + - Proper line height and spacing + +3. **Parameters Table:** + - Name (monospace, highlighted) + - Type (color-coded badge) + - Required/Optional status + - Description + +4. **Response Examples:** + - Tabbed interface for different response types + - JSON syntax highlighting + - Dark theme for code readability + +##### 🎨 **Animation & Transitions:** + +1. **Smooth Transitions:** + - 0.3s cubic-bezier easing for all elements + - Hover effects on interactive elements + - Smooth sidebar collapse/expand + +2. **Visual Feedback:** + - Temporary highlight on section navigation + - Loading spinners for dynamic content + - Focus states for accessibility + +3. **Mobile Interactions:** + - Touch-friendly button sizes + - Swipe-friendly sidebar overlay + - Responsive touch targets + +This template creates a professional, modern API documentation interface that rivals commercial documentation platforms while maintaining full customization control and integration with your Django project. + +### Custom Swagger UI Template Structure + +```html + + + + + + + + + +
+ +
+ + +
+ + + + + +``` + +## Implementation Steps + +### Step 1: Create View Structure +1. Create `apps/api/views/` directory +2. Implement `documentation.py` with custom documentation view +3. Implement `swagger_views.py` with authentication views +4. Update `__init__.py` to export views + +### Step 2: Create Templates +1. Create `templates/api/documentation.html` with responsive design +2. Create `templates/swagger/ui.html` with fixed header +3. Create `templates/swagger/auth.html` for token management +4. Ensure all templates are mobile-responsive + +### Step 3: Update URL Configuration +1. Import required decorators and views +2. Update schema_view configuration for admin-only access +3. Create protected swagger_urlpatterns +4. Add documentation routes to main urlpatterns + +### Step 4: Enhance Security +1. Add `@staff_member_required` decorators to all views +2. Update middleware for token handling +3. Protect all documentation endpoints +4. Test unauthorized access redirects + +### Step 5: Customize and Test +1. Update API structure with your project's endpoints +2. Customize branding and colors +3. Test responsive design on different devices +4. Verify authentication flow works correctly + +## Final Notes + +This implementation provides: +- **Complete security**: Only admin users can access documentation +- **Beautiful UI**: Modern, responsive design with smooth animations +- **Token management**: Full authentication system for API testing +- **Integration**: Seamless connection between different documentation systems +- **Customizable**: Easy to adapt for any Django project + +The system maintains the professional look while providing all the functionality needed for comprehensive API documentation and testing. + +This implementation can be adapted to any Django project by: +1. Updating the project-specific details (names, URLs, branding) +2. Customizing the API structure in the documentation view +3. Adjusting the authentication system if needed +4. Modifying the styling to match your project's design system diff --git a/docs/MultiLanguageJSONWidget.md b/docs/MultiLanguageJSONWidget.md new file mode 100644 index 0000000..425c883 --- /dev/null +++ b/docs/MultiLanguageJSONWidget.md @@ -0,0 +1,66 @@ +## MultiLanguageJSONWidget – توضیحات توصیفی (بدون کد) + +### هدف +- ساخت یک ویجت سفارشی سازگار با Django Unfold برای مدیریت فیلدهای JSON چندزبانه در ادمین. +- مدل داده: لیستی از آبجکت‌ها با کلیدهای `language_code` و `title`. +- تجربه کاربری روان، هماهنگ با پالت رنگی و تم‌های Unfold (لایت/دارک)، و قابل استفاده در تمام اپ‌ها. + +### خلاصه‌ی کارهایی که انجام شد +- ایجاد ویجت چندزبانه‌ای که: + - کدهای زبان فعال را از مدل `dj_language.models.Language` (فقط `status=True`) می‌خواند و در صورت نبود، از `settings.LANGUAGES` استفاده می‌کند. + - کدهای زبان را به‌صورت افقی نمایش می‌دهد؛ با اسکرول افقی که فقط روی hover ظاهر می‌شود (مانند سایدبار Unfold). + - برای زبان‌های دارای مقدار، دکمه‌ی زبان را «پررنگ‌تر» نشان می‌دهد و این زبان‌ها را در ابتدای نوار قرار می‌دهد. + - حالت فعال (Active) را با رنگ‌های primary مطابق پالت UNFOLD نمایش می‌دهد تا فعال بودن زبان واضح باشد. + - برای هر زبان یک ورودی رندر می‌کند که «نوع ورودی» آن قابل تنظیم است: `TextInput`/`Textarea`/`Wysiwyg` (همگی نسخه‌های Unfold). + - مقادیر موجود را شناسایی و در ورودی‌های مربوطه پیش‌نمایش می‌دهد. + +### ذخیره‌سازی و سازگاری با JSONField +- برای سازگاری کامل با `JSONField`، مقدار نهایی در یک input پنهان به‌صورت «رشته‌ی JSON» نگه‌داری و ارسال می‌شود. +- `value_from_datadict` رشته‌ی JSON معتبر تولید می‌کند تا خطای «نوع لیست» در پردازش فرم رخ ندهد. +- ورودی اولیه می‌تواند یکی از حالت‌های زیر باشد و نرمال‌سازی می‌شود: + - `list[dict]` مانند: `[{'language_code': 'fa', 'title': '...'}]` + - `dict` تکی یا نگاشت کدزبان→مقدار + - `str` شامل JSON که ابتدا parse می‌شود. + +### هماهنگی کامل با Unfold +- استفاده از کلاس‌های استایل Unfold برای ورودی‌ها و وضعیت‌ها. +- احترام به متغیرهای رنگی UNFOLD (base/primary/secondary/font) و تغییر خودکار استایل در لایت/دارک. +- اسکرول‌بار با استایل هماهنگ و نمایش فقط هنگام hover. + +### قابل استفاده در تمام اپ‌ها (ماژولار) +- کلاس ویجت به ماژول `utils` منتقل شد تا در هر اپی فقط با import قابل استفاده باشد. +- تمپلیت ویجت به مسیر سراسری `templates/` منتقل شد تا وابستگی به اپ خاصی نداشته باشد. +- اسکریپت‌های موردنیاز در همان تمپلیت اینلاین شده‌اند؛ نیازی به فایل JS جدا نیست. + +### نحوه‌ی استفاده (مفهومی – بدون کد) +- کلاس ویجت را از ماژول ابزارها ایمپورت کنید. +- در فرم ادمین (Meta.widgets)، برای هر فیلد JSON موردنظر، ویجت را تنظیم کنید و «نوع ورودی» دلخواه را مشخص کنید (TextInput/Textarea/Wysiwyg نسخه Unfold). +- پس از ذخیره، مقدار فیلد JSON به‌صورت `list[{'language_code', 'title'}]` تولید/به‌روزرسانی می‌شود. + +### نکات UX و رفتار +- نمایش افقی کدهای زبان با اسکرول افقی روی hover. +- حالت فعال با بوردر/پس‌زمینه‌ی primary مطابق پالت رنگی پروژه. +- وقتی زبانِ خاصی مقدار دارد، دکمه‌ی آن پررنگ‌تر نمایش داده و به ابتدای لیست منتقل می‌شود. +- فوکوس خودکار ورودی پس از فعال‌سازی زبان برای سرعت در ویرایش. + +### سناریوهای پشتیبانی‌شده +- تعداد زیاد زبان‌ها (اسکرول افقی و بدون شکستن چیدمان). +- مقداردهی اولیه از انواع مختلف (list/dict/string JSON). +- تم تیره/روشن و تغییر خودکار رنگ‌ها. + +### محدودیت‌ها و ملاحظات +- فرض بر این است که ساختار داده‌ی هدف، لیست آبجکت‌های `{'language_code', 'title'}` است. +- برای ورودی‌های WYSIWYG، سیاست پاک‌سازی/اعتبارسنجی محتوای HTML به لایه‌های دیگر سپرده شده است. + +### نتیجه +- یک ویجت چندبارمصرف، سازگار با Unfold، با UX دوستانه برای مدیریت محتوای چندزبانه در ادمین. +- پیاده‌سازی به‌گونه‌ای است که بدون وابستگی به اپ خاص، در کل پروژه قابل استفاده باشد. + + + + + + + + + diff --git a/docs/SyncHadis.md b/docs/SyncHadis.md new file mode 100644 index 0000000..265ef27 --- /dev/null +++ b/docs/SyncHadis.md @@ -0,0 +1,635 @@ +# Hadis Sync API Endpoints Documentation + +This document provides comprehensive examples of all sync endpoints in the Hadis API. These endpoints are designed for offline mobile applications and return complete datasets in optimized formats. + +## Overview + +The sync endpoints provide complete data synchronization for offline use. They return all data at once (no pagination) and are optimized with `select_related` and `prefetch_related` for performance. + +## Base URL +``` +https://api.example.com/api/hadis/ +``` + +--- + +## 1. Sync Sects +**Endpoint:** `GET /sync/sects/` +**Purpose:** Get all active sects grouped by sect type (Shia/Sunni) + +### Response Structure +```json +{ + "shia": [ + { + "id": 1, + "title": "Shia Hadith Collections", + "description": "Primary collections of Shia hadith", + "source_types": ["quran", "hadith"] + } + ], + "sunni": [ + { + "id": 2, + "title": "Sunni Hadith Collections", + "description": "Primary collections of Sunni hadith", + "source_types": ["hadith"] + } + ] +} +``` + +--- + +## 2. Sync Categories Tree +**Endpoint:** `GET /sync/categories/tree/` +**Purpose:** Get complete hierarchical category tree grouped by sect type with enhanced child information + +### Response Structure +```json +{ + "count": 12, + "results": { + "shia": { + "sects": { + "1": { + "id": 1, + "sect_type": "shia", + "title": "Shia Hadith Collections", + "description": "Collections of Shia hadith", + "order": 1 + } + }, + "categories": { + "quran": [ + { + "id": 1, + "name": "Tafsir", + "hadis_count": 150, + "has_hadis": false, + "order": 1, + "xmind_file": "http://example.com/media/xmind/tafsir.xmind", + "has_xmind_file": true, + "children": [ + { + "id": 2, + "name": "Surah Al-Fatiha", + "hadis_count": 25, + "has_hadis": true, + "order": 1, + "father_category": { + "id": 1, + "name": "Tafsir", + "sect_id": 1, + "sect_type": "shia", + "source_type": "quran" + }, + "hadis_details": [ + { + "id": 1, + "title": "The Opening", + "title_narrator": "From Abu Hurairah", + "text": "Actions are but by intention...", + "translation": "Actions are but by intention...", + "share_link": "http://example.com/hadis/1" + }, + { + "id": 2, + "title": "Prayer Times", + "title_narrator": "From Abdullah ibn Masud", + "text": "The five daily prayers...", + "translation": "The five daily prayers...", + "share_link": "http://example.com/hadis/2" + } + ], + "children": [] + }, + { + "id": 3, + "name": "Surah Al-Baqarah", + "hadis_count": 125, + "has_hadis": false, + "order": 2, + "father_category": { + "id": 1, + "name": "Tafsir", + "sect_id": 1, + "sect_type": "shia", + "source_type": "quran" + }, + "children": [ + { + "id": 4, + "name": "Verses 1-50", + "hadis_count": 75, + "has_hadis": true, + "father_category": { + "id": 3, + "name": "Surah Al-Baqarah", + "sect_id": 1, + "sect_type": "shia", + "source_type": "quran" + }, + "hadis_details": [ + { + "id": 5, + "title": "About Prayer", + "title_narrator": "From Ali ibn Abi Talib", + "text": "Prayer is the pillar of religion...", + "translation": "Prayer is the pillar of religion...", + "share_link": "http://example.com/hadis/5" + } + ] + } + ] + } + ] + } + ], + "hadith": [] + } + }, + "sunni": { + "sects": { + "2": { + "id": 2, + "sect_type": "sunni", + "title": "Sunni Hadith Collections", + "description": "Collections of Sunni hadith", + "order": 2 + } + }, + "categories": { + "hadith": [ + { + "id": 10, + "name": "Sahih al-Bukhari", + "hadis_count": 2500, + "has_hadis": true, + "hadis_details": [ + { + "id": 100, + "title": "The Beginning of Revelation", + "title_narrator": "From Aisha", + "text": "The first revelation came to Prophet Muhammad...", + "translation": "The first revelation came to Prophet Muhammad...", + "share_link": "http://example.com/hadis/100" + }, + { + "id": 101, + "title": "Prayer in the Mosque", + "title_narrator": "From Umar ibn Khattab", + "text": "The reward of prayer in congregation...", + "translation": "The reward of prayer in congregation...", + "share_link": "http://example.com/hadis/101" + } + ], + "children": [] + }, + { + "id": 11, + "name": "Sahih Muslim", + "hadis_count": 2200, + "has_hadis": false, + "children": [ + { + "id": 12, + "name": "Book of Faith", + "hadis_count": 150, + "has_hadis": true, + "father_category": { + "id": 11, + "name": "Sahih Muslim", + "sect_id": 2, + "sect_type": "sunni", + "source_type": "hadith" + }, + "hadis_details": [ + { + "id": 200, + "title": "Faith and Actions", + "title_narrator": "From Abu Hurairah", + "text": "Faith consists of more than sixty branches...", + "translation": "Faith consists of more than sixty branches...", + "share_link": "http://example.com/hadis/200" + } + ] + } + ] + } + ] + } + } + } +} +``` + +--- + +## 3. Sync Hadis +**Endpoint:** `GET /sync/hadis/` +**Purpose:** Get all hadis data for offline synchronization + +### Response Structure +```json +{ + "count": 1500, + "results": { + "1": { + "id": 1, + "number": 1, + "category_id": 2, + "title": "The Opening", + "title_narrator": "From Abu Hurairah", + "text": "Actions are but by intention...", + "description": "This hadith emphasizes the importance of intention in all actions...", + "translations": { + "en": "Actions are but by intention...", + "ar": "إنما الأعمال بالنيات...", + "fa": "اعمال به نیت است..." + }, + "explanation": "This hadith emphasizes the importance of intention in all actions...", + "address": "Sahih al-Bukhari, Book of Revelation", + "hadis_status": { + "id": 1, + "title": "Sahih", + "color": "green" + }, + "hadis_status_text": "Authentic", + "share_link": "http://example.com/hadis/1", + "tags": [ + {"id": 1, "title": "Intention"}, + {"id": 2, "title": "Actions"} + ], + "links": { + "audio": "http://example.com/audio/hadis1.mp3", + "video": "http://example.com/video/hadis1.mp4" + }, + "transmitters": [ + { + "id": 1, + "order": 1, + "is_gap": false, + "narrator_layer": "sahaba", + "transmitter": { + "id": 1, + "full_name": "Abu Hurairah", + "birth_year_hijri": 18, + "death_year_hijri": 59, + "madhhab": "sunni", + "description": "One of the most prolific narrators of hadith", + "reliability": "very_reliable" + } + } + ], + "references": [ + { + "id": 1, + "title": "Sahih al-Bukhari", + "images": [ + { + "id": 1, + "image": "http://example.com/media/books/bukhari_cover.jpg", + "order": 1, + "description": "Front cover of Sahih al-Bukhari" + } + ], + "authors": [ + { + "id": 1, + "name": "Muhammad ibn Isma'il al-Bukhari" + } + ], + "description": "The most authentic collection of hadith compiled by Imam Bukhari" + } + ], + "corrections": [ + { + "id": 1, + "title": "Translation Correction", + "description": "Corrected translation for better accuracy", + "translation": { + "en": "Actions are judged by intentions...", + "ar": "إنما الأعمال بالنيات...", + "fa": "اعمال به نیت ها قضاوت می شود..." + } + } + ] + }, + "2": { + "id": 2, + "number": 2, + "category_id": 3, + "title": "Five Pillars of Islam", + "title_narrator": "From Abdullah ibn Umar", + "text": "Islam is built on five pillars...", + "translations": { + "en": "Islam is built on five pillars...", + "ar": "بني الإسلام على خمس...", + "fa": "اسلام بر پنج پایه استوار است..." + }, + "explanation": "This hadith outlines the fundamental practices of Islam...", + "address": "Sahih al-Bukhari, Book of Faith", + "hadis_status": { + "id": 1, + "title": "Sahih", + "color": "green" + }, + "hadis_status_text": "Authentic", + "share_link": "http://example.com/hadis/2", + "tags": [ + {"id": 3, "title": "Pillars of Islam"}, + {"id": 4, "title": "Faith"} + ], + "links": { + "audio": "http://example.com/audio/hadis2.mp3" + } + } + } +} +``` + +--- + +## 4. Sync Narrators +**Endpoint:** `GET /sync/narrators/` +**Purpose:** Get all transmitter (narrator) data with biographical information and scholarly opinions + +### Response Structure +```json +{ + "count": 200, + "results": { + "1": { + "id": 1, + "full_name": "Abu Daud Sulaiman ibn al-Ash'ath al-Azdi al-Sijistani", + "biographical": { + "personal_info": { + "full_name": "Abu Daud Sulaiman ibn al-Ash'ath al-Azdi al-Sijistani", + "kunya": "Abu Daud", + "known_as": "Imam Abu Daud", + "nickname": "Al-Sijistani" + }, + "dates": { + "birth_year_hijri": 202, + "death_year_hijri": 275, + "age_at_death": 73 + }, + "locations": { + "origin": "Sijistan (modern Sistan)", + "lived_in": "Basra, Baghdad", + "died_in": "Basra" + }, + "religious_profile": { + "reliability": "very_reliable", + "madhhab": "shafii", + "in_sahih_muslim": true, + "in_sahih_bukhari": false + }, + "description": "One of the six canonical hadith collectors. Known for his compilation 'Sunan Abu Daud'.", + "thumbnail": "http://example.com/media/transmitters/abu_daud.jpg" + }, + "scholars_opinions": [ + { + "id": 1, + "scholar_name": "Imam al-Nawawi", + "opinion_text": "Abu Daud is reliable and trustworthy in his transmissions. His collection is one of the six authentic books.", + "status": "confirmed", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" + }, + { + "id": 2, + "scholar_name": "Ibn Kathir", + "opinion_text": "Abu Daud was meticulous in his research and only included authentic hadith in his collection.", + "status": "confirmed", + "created_at": "2024-01-16T14:20:00Z", + "updated_at": "2024-01-16T14:20:00Z" + } + ] + }, + "2": { + "id": 2, + "full_name": "Muhammad ibn Isma'il al-Bukhari", + "biographical": { + "personal_info": { + "full_name": "Muhammad ibn Isma'il al-Bukhari", + "kunya": "Abu Abdullah", + "known_as": "Imam al-Bukhari", + "nickname": "The Collector" + }, + "dates": { + "birth_year_hijri": 194, + "death_year_hijri": 256, + "age_at_death": 62 + }, + "locations": { + "origin": "Bukhara (modern Uzbekistan)", + "lived_in": "Bukhara, Nishapur, Baghdad", + "died_in": "Khartank, near Bukhara" + }, + "religious_profile": { + "reliability": "very_reliable", + "madhhab": "hanafi", + "in_sahih_muslim": true, + "in_sahih_bukhari": true + }, + "description": "The compiler of Sahih al-Bukhari, considered the most authentic hadith collection by Muslims.", + "thumbnail": "http://example.com/media/transmitters/bukhari.jpg" + }, + "scholars_opinions": [ + { + "id": 3, + "scholar_name": "Imam Muslim", + "opinion_text": "Al-Bukhari is the most knowledgeable person regarding the conditions of narrators and the defects of hadith.", + "status": "confirmed", + "created_at": "2024-01-17T09:15:00Z", + "updated_at": "2024-01-17T09:15:00Z" + }, + { + "id": 4, + "scholar_name": "Ibn Hajar al-Asqalani", + "opinion_text": "The hadith of al-Bukhari are the most authentic after the Quran.", + "status": "confirmed", + "created_at": "2024-01-18T11:45:00Z", + "updated_at": "2024-01-18T11:45:00Z" + } + ] + } + } +} +``` + +--- + +## 5. Sync References +**Endpoint:** `GET /sync/references/` +**Purpose:** Get all book reference data with basic information, detailed publication info, and related hadises + +### Response Structure +```json +{ + "count": 50, + "results": { + "1": { + "id": 1, + "title": "Sahih al-Bukhari", + "basic_info": { + "title": "Sahih al-Bukhari", + "authors": [ + { + "id": 1, + "name": "Muhammad ibn Isma'il al-Bukhari" + } + ], + "rating": 5.0, + "description": "The most authentic collection of hadith compiled by Imam Bukhari. Contains over 7000 hadith with complete chains of narration.", + "volume": "9 volumes" + }, + "information": { + "language": "Arabic", + "isbn": "978-1234567890", + "year_of_publication": "846", + "number_of_pages": 4200, + "volume_info": "9 volumes", + "rating": 5.0 + }, + "hadis": [ + { + "id": 1, + "title": "The Opening", + "title_narrator": "From Abu Hurairah", + "text": "Actions are but by intention...", + "translation": "Actions are but by intention...", + "share_link": "http://example.com/hadis/1" + }, + { + "id": 2, + "title": "Five Pillars of Islam", + "title_narrator": "From Abdullah ibn Umar", + "text": "Islam is built on five pillars...", + "translation": "Islam is built on five pillars...", + "share_link": "http://example.com/hadis/2" + }, + { + "id": 100, + "title": "The Beginning of Revelation", + "title_narrator": "From Aisha", + "text": "The first revelation came to Prophet Muhammad...", + "translation": "The first revelation came to Prophet Muhammad...", + "share_link": "http://example.com/hadis/100" + } + ] + }, + "2": { + "id": 2, + "title": "Sahih Muslim", + "basic_info": { + "title": "Sahih Muslim", + "authors": [ + { + "id": 2, + "name": "Muslim ibn al-Hajjaj al-Naysaburi" + } + ], + "rating": 4.9, + "description": "Second most authentic hadith collection, compiled by Imam Muslim. Known for its strict criteria for authenticity.", + "volume": "8 volumes" + }, + "information": { + "language": "Arabic", + "isbn": "978-0987654321", + "year_of_publication": "875", + "number_of_pages": 3800, + "volume_info": "8 volumes", + "rating": 4.9 + }, + "hadis": [ + { + "id": 3, + "title": "Faith and Actions", + "title_narrator": "From Abu Hurairah", + "text": "Faith consists of more than sixty branches...", + "translation": "Faith consists of more than sixty branches...", + "share_link": "http://example.com/hadis/3" + }, + { + "id": 4, + "title": "Purification", + "title_narrator": "From Abu Hurairah", + "text": "The key to prayer is purification...", + "translation": "The key to prayer is purification...", + "share_link": "http://example.com/hadis/4" + } + ] + }, + "3": { + "id": 3, + "title": "Tafsir Ibn Kathir", + "basic_info": { + "title": "Tafsir Ibn Kathir", + "authors": [ + { + "id": 3, + "name": "Ibn Kathir" + } + ], + "rating": 4.8, + "description": "Comprehensive tafsir (exegesis) of the Quran, combining hadith and scholarly opinions.", + "volume": "4 volumes" + }, + "information": { + "language": "Arabic", + "isbn": "978-1122334455", + "year_of_publication": "1370", + "number_of_pages": 2800, + "volume_info": "4 volumes", + "rating": 4.8 + }, + "hadis": [ + { + "id": 5, + "title": "Quranic Interpretation", + "title_narrator": "From Ibn Abbas", + "text": "The Quran should be interpreted in light of the Prophet's explanations...", + "translation": "The Quran should be interpreted in light of the Prophet's explanations...", + "share_link": "http://example.com/hadis/5" + } + ] + } + } +} +``` + +--- + +## Usage Notes + +### **Performance Optimizations** +- All sync endpoints use `NoPagination` for complete dataset retrieval +- Database queries are optimized with `select_related` and `prefetch_related` +- Related data is prefetched to avoid N+1 query problems + +### **Data Relationships** +- **Categories Tree**: Hierarchical structure with father category references and embedded hadis details +- **Hadis**: Include full translation dictionaries and metadata +- **Narrators**: Biographical data grouped with scholarly opinions +- **References**: Publication details with embedded related hadis + +### **Offline Synchronization** +- Designed for mobile apps requiring complete offline datasets +- Structured for efficient client-side caching and updates +- Includes all necessary related data to minimize API calls + +### **Response Format** +All sync endpoints return: +```json +{ + "count": , + "results": { + "": { ...record_data... } + } +} +``` + +This format allows for easy lookup by ID and provides total count information. diff --git a/docs/calendar_prayer_guide.fa.md b/docs/calendar_prayer_guide.fa.md new file mode 100644 index 0000000..9da2d8e --- /dev/null +++ b/docs/calendar_prayer_guide.fa.md @@ -0,0 +1,263 @@ +# راهنمای جامع تقویم دابودبی و محاسبه اوقات شرعی + +این مستند برای آشنایی همکاران فنی (مانند تیم فلاتر) و غیر فنی با سازوکار تقویم پروژه «امام جواد» و همچنین منطق محاسبه اوقات شرعی تهیه شده است. مطالب شامل معرفی ساختار دیتای تقویم، توضیح کامل API تنظیمات قمری (`adjustemnts`)، نحوه بهره‌گیری در سناریوهای واقعی و نمونه‌کدهای کاربردی است. + +## نمای کلی سیستم تقویم + +- **ماژول:** `apps/dobodbi_calendar` +- **مدل اصلی:** `CalendarOccasions` + - فیلدهای کلیدی: `title` عنوان مناسبت، `occasion_type` نوع تاریخ (میلادی یا قمری)، `dates` لیست تاریخ‌ها، `event_type` دسته‌بندی (ملی، مذهبی و ...)، `is_yearly` تکرار سالانه، تایم‌استمپ‌های `created_at` و `updated_at`. +- **سریالایزر:** `CalendarSerializer` که تاریخ‌ها را به ساختار قابل مصرف برای کلاینت تبدیل می‌کند و نوع مناسبت را در فیلد `type` قرار می‌دهد. +- **نمای لیستی:** `CalendarList` با مسیر `/api/calendar/occasions/` که بدون صفحه‌بندی پاسخ می‌دهد و آخرین زمان به‌روزرسانی را برمی‌گرداند تا همگام‌سازی کلاینت راحت‌تر انجام شود. +- **تنظیمات پویا:** با استفاده از `dynamic_preferences` و کلید `calendar__Adjustment` ذخیره می‌شود. مقدار خام این تنظیمات در پایگاه داده نگهداری شده و از طریق پنل ادمین قابل ویرایش است. + +## API تنظیمات قمری (Adjustemnts) + +- **مسیر:** `GET /api/calendar/adjustemnts/` +- **منبع داده:** ترجمهٔ مقدار ذخیره شده در `dynamic_preferences` برای کلید `calendar__Adjustment`. +- **کارکرد کلی:** ارائهٔ سناریوهای از پیش تعریف‌شده برای تطبیق تقویم قمری با واقعیت رؤیت هلال یا تقویم رسمی کشورها. + +### ساختار پاسخ نمونه + +```json +[ + { + "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": { "...": "..." } + }, + { + "adjust": 1, + "current": 0, + "map": { "...": "..." } + } +] +``` + +### معنی فیلدها + +| فیلد | توضیح | +| --- | --- | +| `adjust` | مقدار جبرانی: `-1` یک روز کم می‌کند، `0` حالت مرجع، `+1` یک روز اضافه می‌کند. | +| `current` | وضعیت فعال که می‌تواند توسط کلاینت یا پنل ادمین برای علامت‌گذاری حالت انتخابی استفاده شود (در نمونه فعلی صفر است). | +| `map` | دیکشنری سال قمری ↦ آرایه شامل ۱ + ۱۲ مقدار: عدد اول تعداد روزهای سال (۳۵۴ یا ۳۵۵)، ۱۲ عدد بعدی طول هر ماه قمری. | + +### انتخاب حالت مناسب با مثال + +| حالت | زمان استفاده پیشنهادی | مثال عملی | +| --- | --- | --- | +| `adjust = 0` | حالت مرجع و عمومی | نمایش تقویم در سامانه آموزشی بدون نیاز به تصحیح محلی. | +| `adjust = -1` | مناطقی که شروع ماه قمری را یک روز زودتر اعلام می‌کنند یا پس از رصد مشخص می‌شود هلال یک روز زودتر دیده شده | اگر در ایران رمضان ۲۹ روز اعلام شود اما محاسبه‌گر ۳۰ روزه باشد، با `-1` آخرین روز حذف می‌شود. | +| `adjust = +1` | مناطقی که آغاز ماه را دیرتر اعلام می‌کنند یا برای همگام‌سازی با تقویم رسمی نیاز به افزودن روز دارند | زمانی که کشوری عید قربان را یک روز دیرتر می‌گیرد؛ با `+1` روز اضافه می‌شود. | + +### سناریوی قدم‌به‌قدم: تعیین تاریخ عید فطر + +1. دریافت داده: `configs = GET /api/calendar/adjustemnts/`. +2. انتخاب حالت: `defaultConfig = configs.find(c => c.adjust === 0)`. +3. استخراج سال هدف: `ramadanProfile = defaultConfig.map['1445']`. +4. محاسبه طول رمضان: مقدار شاخص ۹ (ماه نهم). اگر ۲۹ بود → عید فطر روز ۲۹ رمضان، اگر ۳۰ بود → روز ۳۰. +5. در صورت اختلاف رسمی، کافی است پیکربندی دیگری را انتخاب کنید (مثلاً `adjust = +1`) تا روز اضافه لحاظ شود. + +### سناریوی همگام‌سازی تقویم در اپلیکیشن + +1. در اولین اجرا، هرسه پیکربندی را ذخیره کنید. +2. متناسب با موقعیت کاربر (یا انتخاب کاربر)، `adjust` مناسب را فعال نگه دارید. مقدار انتخابی را می‌توانید در کلاینت یا سرور ذخیره کنید. +3. برای تبدیل «روز سال قمری» به روز میلادی: + - اختلاف روز تا ابتدای سال قمری را با جمع ۱۲ ماه محاسبه کنید. + - با استفاده از تاریخ میلادی مرجع (مثلاً شروع سال قمری در تقویم رسمی)، اختلاف را اعمال کنید تا تاریخ میلادی به‌دست آید. +4. اگر داده‌های `CalendarOccasions` نوع `lunar` داشتند، از نقشه انتخاب‌شده برای محاسبه تاریخ معادل استفاده کنید و مقدار نهایی را به رابط کاربری نمایش دهید. + +## راهنمای محاسبه اوقات شرعی + +مبنای پروژه فایل `prayer_times_calculation_guide.html` است که مراحل استفاده از الگوریتم **PrayTimes** را تشریح کرده است. خلاصه فرآیند: + +1. **ورودی‌ها:** تاریخ میلادی، مختصات (عرض/طول جغرافیایی، ارتفاع)، روش محاسبه (مثلاً Tehran، MWL)، روش جبران عرض‌های بالا و فرمت خروجی (۱۲ یا ۲۴ ساعته). +2. **محاسبه تاریخ ژولیَن (Julian Date):** `jdate = julian(year, month, day) - longitude / (15 × 24)`. +3. **موقعیت خورشید:** با تابعی مشابه `sunPosition(jdate)` زاویه میل خورشید (`declination`) و معادله زمان (`equation of time`) به‌دست می‌آید. +4. **محاسبه اوقات پایه:** + - فجر، طلوع، مغرب، عشا: با `sunAngleTime` و زاویه‌های مخصوص هر روش محاسبه می‌شوند. + - ظهر: با `midDay`. + - عصر: با `asrTime` و فاکتور مربوط به مذهب (1 برای شافعی/جعفری، 2 برای حنفی). +5. **تنظیم اختلاف طول جغرافیایی و منطقه زمانی:** + - `offset = timezoneHours - longitude / 15` + - جمع این مقدار با همه زمان‌های محاسبه‌شده. +6. **جبران عرض‌های جغرافیایی بالا:** در صورت انتخاب روش‌هایی مثل `NightMiddle` یا `AngleBased`، طول شب محاسبه و زمان‌های فجر/عشا تعدیل می‌شوند. +7. **تبدیل خروجی اعشاری به ساعت:** + - ساعت = بخش صحیح عدد. + - دقیقه = `(عدد - ساعت) × 60`. + - در صورت نیاز به فرمت ۱۲ ساعته، AM/PM مطابق قواعد اضافه می‌شود. + +### چه ابزاری برای محاسبه استفاده کنیم؟ + +- **بک‌اند (Python/Django):** در صورت نیاز به محاسبه سمت سرور می‌توانید از پیاده‌سازی استاندارد PrayTimes یا کتابخانه‌های معتبری مانند `praytimes` (نسخه پایتونی) بهره ببرید. نتیجه را می‌توان کش کرد و فقط در صورت تغییر مختصات یا تاریخ دوباره محاسبه نمود. +- **کلاینت فلاتر:** استفاده از کلاس سفارشی پروژه یا پکیج‌های آماده نظیر [`adhan_dart`](https://pub.dev/packages/adhan_dart) برای محاسبات سریع و دقیق توصیه می‌شود. +- **وب/جاوااسکریپت:** کتابخانه‌هایی مثل [`adhan`](https://github.com/batoulapps/adhan-js) یا نسخهٔ رسمی PrayTimes.js به‌خوبی نیاز را پوشش می‌دهند. + +## نمونه کد فلاتر + +### دریافت و استفاده از تنظیمات قمری + +```dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class LunarAdjustConfig { + final int adjust; + final Map> map; + + LunarAdjustConfig({required this.adjust, required this.map}); + + factory LunarAdjustConfig.fromJson(Map json) { + return LunarAdjustConfig( + adjust: json['adjust'] as int, + map: (json['map'] as Map).map( + (key, value) => MapEntry(key, List.from(value)), + ), + ); + } +} + +Future> fetchAdjustments() async { + final res = await http.get(Uri.parse('https://example.com/api/calendar/adjustemnts/')); + final data = jsonDecode(res.body) as List; + return data.map((item) => LunarAdjustConfig.fromJson(item)).toList(); +} + +DateTime applyLunarOffset({ + required DateTime hijriYearStart, + required LunarAdjustConfig config, + required String hijriYear, + required int hijriMonthIndex, + required int hijriDay, +}) { + final months = config.map[hijriYear] ?? []; + if (months.length != 13) { + throw ArgumentError('ساختار سال قمری نامعتبر است'); + } + + final daysFromYearStart = months + .sublist(1, hijriMonthIndex) + .fold(0, (acc, value) => acc + value); + + final totalOffset = daysFromYearStart + (hijriDay - 1) + config.adjust; + return hijriYearStart.add(Duration(days: totalOffset)); +} +``` + +### محاسبه اوقات شرعی با `adhan_dart` + +```dart +import 'package:adhan_dart/adhan_dart.dart'; + +PrayerTimes calculatePrayerTimes({ + required DateTime date, + required Coordinates coordinates, +}) { + final params = CalculationMethod.tehran(); + params.madhab = Madhab.shafi; + final times = PrayerTimes(coordinates, date, params); + return times; +} + +void main() async { + final configs = await fetchAdjustments(); + final defaultConfig = configs.firstWhere((c) => c.adjust == 0, orElse: () => configs.first); + + final hijriStart = DateTime(2023, 7, 19); // تاریخ میلادی شروع سال 1445 به تقویم رسمی + final eidDate = applyLunarOffset( + hijriYearStart: hijriStart, + config: defaultConfig, + hijriYear: '1445', + hijriMonthIndex: 10, // ماه شوال (پس از رمضان) + hijriDay: 1, + ); + + final times = calculatePrayerTimes( + date: eidDate, + coordinates: Coordinates(35.6892, 51.3890), + ); + + print('اذان صبح: ${times.fajrTime}'); + print('اذان مغرب: ${times.maghribTime}'); +} +``` + +## نمونه کد جاوااسکریپت (وب یا Node.js) + +### دریافت تنظیمات و محاسبه تاریخ قمری + +```js +import fetch from 'node-fetch'; + +async function fetchAdjustments() { + const res = await fetch('https://example.com/api/calendar/adjustemnts/'); + return res.json(); +} + +function applyLunarOffset({ hijriYearStart, config, hijriYear, hijriMonthIndex, hijriDay }) { + const months = config.map[hijriYear]; + if (!months || months.length !== 13) { + throw new Error('ساختار سال قمری نامعتبر است'); + } + + const daysBeforeMonth = months.slice(1, hijriMonthIndex).reduce((sum, value) => sum + value, 0); + const totalOffset = daysBeforeMonth + (hijriDay - 1) + config.adjust; + + const result = new Date(hijriYearStart); + result.setDate(result.getDate() + totalOffset); + return result; +} + +// مثال استفاده +const configs = await fetchAdjustments(); +const preferred = configs.find((c) => c.adjust === 1) ?? configs[0]; +const eidAlAdha = applyLunarOffset({ + hijriYearStart: new Date('2024-06-08'), // شروع سال 1446 در تقویم رسمی منطقه هدف + config: preferred, + hijriYear: '1446', + hijriMonthIndex: 12, // ذی‌الحجه + hijriDay: 10, +}); + +console.log('تاریخ میلادی عید قربان:', eidAlAdha.toISOString().slice(0, 10)); +``` + +### محاسبه اوقات شرعی با کتابخانه `adhan` + +```js +import { PrayerTimes, Coordinates, CalculationMethod } from 'adhan'; + +function calculatePrayerTimes(date, lat, lng) { + const params = CalculationMethod.Tehran(); + const coordinates = new Coordinates(lat, lng); + const times = new PrayerTimes(coordinates, date, params); + + return { + fajr: times.fajr.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + sunrise: times.sunrise.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + dhuhr: times.dhuhr.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + asr: times.asr.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + maghrib: times.maghrib.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + isha: times.isha.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + }; +} + +const todayTimes = calculatePrayerTimes(new Date(), 35.6892, 51.3890); +console.log(todayTimes); +``` + +## جمع‌بندی + +- تقویم پروژه از ترکیب مدل `CalendarOccasions` و تنظیمات پویا `calendar__Adjustment` تشکیل شده و API ویژهٔ `adjustemnts` سه سناریوی جبران قمری را ارائه می‌کند. +- برای محاسبهٔ اوقات شرعی می‌توان از الگوریتم مبتنی بر `PrayTimes` استفاده کرد که با ورودی‌های تاریخ، مختصات و روش محاسبه، زمان‌های اذان را بازمی‌گرداند. +- نمونه کدهای فلاتر و جاوااسکریپت نشان می‌دهند چگونه می‌توان در کلاینت هم تاریخ‌های قمری را به میلادی تبدیل کرد و هم اوقات شرعی را به‌صورت محلی محاسبه نمود. diff --git a/docs/categories_api_check.MD b/docs/categories_api_check.MD new file mode 100644 index 0000000..a531460 --- /dev/null +++ b/docs/categories_api_check.MD @@ -0,0 +1,535 @@ +# 📋 Categories API Response Examples + +## 1. GET `/api/hadis/categories/` +**Description**: Returns all categories with pagination (limited to 16 items per page). + +**Response Structure:** +```json +{ + "count": 56, + "next": "http://127.0.0.1:8000/api/hadis/categories/?limit=16&offset=16", + "previous": null, + "results": [ + { + "id": 324, + "title": "Толкование Корана", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 3 + }, + { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 331, + "title": "Толкование суры Аль-Бакара", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 332, + "title": "Толкование суры Аль Имран", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 3, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 325, + "title": "Аяты постановлений", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_325_%D0%90%D1%8F%D1%82%D1%8B_%D0%BF%D0%BE%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B9.xmind", + "children_count": 3 + }, + { + "id": 333, + "title": "Аяты о молитве", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 334, + "title": "Аяты о посте", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 335, + "title": "Аяты о закяте", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 3, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 326, + "title": "Коранические истории", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 3, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_326_%D0%9A%D0%BE%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B5_%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8.xmind", + "children_count": 0 + }, + { + "id": 336, + "title": "Истории пророков", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 337, + "title": "Истории праведников", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 327, + "title": "Достоинства сур", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 4, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_327_%D0%94%D0%BE%D1%81%D1%82%D0%BE%D0%B8%D0%BD%D1%81%D1%82%D0%B2%D0%B0_%D1%81%D1%83%D1%80.xmind", + "children_count": 0 + }, + { + "id": 328, + "title": "Чудеса Корана", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 5, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_328_%D0%A7%D1%83%D0%B4%D0%B5%D1%81%D0%B0_%D0%9A%D0%BE%D1%80%D0%B0%D0%BD%D0%B0.xmind", + "children_count": 0 + }, + { + "id": 329, + "title": "Коранические науки", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 6, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 338, + "title": "Книга очищения", + "sect_id": 20, + "sect_type": "shia", + "source_type": "hadith", + "description": null, + "order": 1, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_338_%D0%9A%D0%BD%D0%B8%D0%B3%D0%B0_%D0%BE%D1%87%D0%B8%D1%89%D0%B5%D0%BD%D0%B8%D1%8F.xmind", + "children_count": 0 + }, + { + "id": 344, + "title": "Омовение", + "sect_id": 20, + "sect_type": "shia", + "source_type": "hadith", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + } + ] +} +``` + + +## 2. GET `/api/hadis/categories/{sect_type}/` +**Description**: Returns categories filtered by sect type (shia or sunni). + +**Parameters:** +- `sect_type`: Islamic sect type (`shia` or `sunni`) + +**Example URL:** `GET /api/hadis/categories/shia/` + +**Response Structure:** +```json +{ + "count": 28, + "next": "http://127.0.0.1:8000/api/hadis/categories/shia/?limit=16&offset=16", + "previous": null, + "results": [ + { + "id": 324, + "title": "Толкование Корана", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 3 + }, + { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 331, + "title": "Толкование суры Аль-Бакара", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 332, + "title": "Толкование суры Аль Имран", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 3, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 325, + "title": "Аяты постановлений", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_325_%D0%90%D1%8F%D1%82%D1%8B_%D0%BF%D0%BE%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B9.xmind", + "children_count": 3 + }, + { + "id": 333, + "title": "Аяты о молитве", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 334, + "title": "Аяты о посте", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 335, + "title": "Аяты о закяте", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 3, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 326, + "title": "Коранические истории", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 3, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_326_%D0%9A%D0%BE%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B5_%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8.xmind", + "children_count": 0 + }, + { + "id": 336, + "title": "Истории пророков", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 337, + "title": "Истории праведников", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 2, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 327, + "title": "Достоинства сур", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 4, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_327_%D0%94%D0%BE%D1%81%D1%82%D0%BE%D0%B8%D0%BD%D1%81%D1%82%D0%B2%D0%B0_%D1%81%D1%83%D1%80.xmind", + "children_count": 0 + }, + { + "id": 328, + "title": "Чудеса Корана", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 5, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_328_%D0%A7%D1%83%D0%B4%D0%B5%D1%81%D0%B0_%D0%9A%D0%BE%D1%80%D0%B0%D0%BD%D0%B0.xmind", + "children_count": 0 + }, + { + "id": 329, + "title": "Коранические науки", + "sect_id": 20, + "sect_type": "shia", + "source_type": "quran", + "description": null, + "order": 6, + "slug": null, + "xmind_file": null, + "children_count": 0 + }, + { + "id": 338, + "title": "Книга очищения", + "sect_id": 20, + "sect_type": "shia", + "source_type": "hadith", + "description": null, + "order": 1, + "slug": null, + "xmind_file": "http://127.0.0.1:8000/media/hadis/xmind_files/category_338_%D0%9A%D0%BD%D0%B8%D0%B3%D0%B0_%D0%BE%D1%87%D0%B8%D1%89%D0%B5%D0%BD%D0%B8%D1%8F.xmind", + "children_count": 0 + }, + { + "id": 344, + "title": "Омовение", + "sect_id": 20, + "sect_type": "shia", + "source_type": "hadith", + "description": null, + "order": 1, + "slug": null, + "xmind_file": null, + "children_count": 0 + } + ] +} +``` + + +## 3. GET `/api/hadis/categories/{sect_type}/{slug}/` +**Description**: Returns child categories of a specific category (by slug) within the sect type. + +**Parameters:** +- `sect_type`: Islamic sect type (`shia` or `sunni`) +- `slug`: URL slug of the parent category + +**Example URL:** `GET /api/hadis/categories/shia/some-category-slug/` + +**Response Structure:** +```json +{ + "count": 3, + "next": null, + "previous": null, + "results": [ + { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "source_type": "quran", + "sect_id": 20, + "sect_type": "shia", + "children_count": 0 + }, + { + "id": 331, + "title": "Толкование суры Аль-Бакара", + "source_type": "quran", + "sect_id": 20, + "sect_type": "shia", + "children_count": 0 + }, + { + "id": 332, + "title": "Толкование суры Аль Имран", + "source_type": "quran", + "sect_id": 20, + "sect_type": "shia", + "children_count": 0 + } + ] +} +``` + +## 4. GET `/api/hadis/categories/{sect_type}/{slug}/{source_type}/` +**Description**: Returns child categories of a specific category (by slug) within the sect type, filtered by source type. + +**Parameters:** +- `sect_type`: Islamic sect type (`shia` or `sunni`) +- `slug`: URL slug of the parent category +- `source_type`: Source material type (`quran`, `hadith`, `history`, `fatwa`, `quote`) + +**Example URL:** `GET /api/hadis/categories/shia/some-category-slug/quran/` + +**Response Structure:** +```json +{ + "count": 3, + "next": null, + "previous": null, + "results": [ + { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "source_type": "quran", + "slug": "-1", + "sect_id": 20, + "sect_type": "shia", + "children_count": 0, + "has_hadis": false + }, + { + "id": 331, + "title": "Толкование суры Аль-Бакара", + "source_type": "quran", + "slug": "-19", + "sect_id": 20, + "sect_type": "shia", + "children_count": 0, + "has_hadis": false + }, + { + "id": 332, + "title": "Толкование суры Аль Имран", + "source_type": "quran", + "slug": "-33", + "sect_id": 20, + "sect_type": "shia", + "children_count": 0, + "has_hadis": false + } + ] +} + + +``` + +--- + + + +## 🔍 Field Descriptions + +- **`id`**: Unique identifier for the category +- **`title`**: Category name in Russian +- **`sect_id`**: ID of the Islamic sect +- **`sect_type`**: Type of Islamic sect (`shia` or `sunni`) +- **`source_type`**: Type of source material (`quran`, `hadith`, `history`, `fatwa`, `quote`) +- **`description`**: Optional description of the category +- **`order`**: Display order within the hierarchy +- **`slug`**: URL-friendly identifier (auto-generated from title, unique) +- **`xmind_file`**: URL to associated XMind mind map file (optional) +- **`children_count`**: Number of active child categories +- **`has_hadis`**: Boolean indicating if category contains hadith content (tree endpoints only) \ No newline at end of file diff --git a/docs/exchange_token_api.md b/docs/exchange_token_api.md new file mode 100644 index 0000000..120c108 --- /dev/null +++ b/docs/exchange_token_api.md @@ -0,0 +1,379 @@ +# API تبدیل توکن موقت (Exchange Token) + +## 📝 توضیحات + +این API برای تبدیل توکن موقت (temporary token) به اطلاعات کاربر واقعی استفاده می‌شود. + +--- + +## 🔗 Endpoint + +``` +POST /api/account/exchange-token/ +``` + +**دسترسی:** عمومی (AllowAny) + +--- + +## 📥 Request Body + +```json +{ + "temp_token": "abc123xyz" +} +``` + +--- + +## 📤 Response + +### ✅ موفق (200 OK) + +```json +{ + "success": true, + "message": "ورود موفق", + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } +} +``` + +**نکته:** +- `token`: توکن احراز هویت واقعی کاربر (DRF Token) که برای درخواست‌های بعدی استفاده می‌شود +- `avatar`: در صورت عدم وجود، مقدار `null` برگردانده می‌شود + +### ❌ خطا - توکن نامعتبر یا منقضی (404 NOT FOUND) + +```json +{ + "success": false, + "message": "توکن نامعتبر یا منقضی شده است" +} +``` + +### ❌ خطا - کاربر یافت نشد (404 NOT FOUND) + +```json +{ + "success": false, + "message": "کاربر یافت نشد" +} +``` + +### ❌ خطا - توکن ارسال نشده (400 BAD REQUEST) + +```json +{ + "success": false, + "message": "توکن نامعتبر است" +} +``` + +--- + +## 🧪 تست با curl + +### مرحله 1: تولید توکن موقت + +```bash +curl -X POST http://localhost:8000/api/courses/1/online/token/ \ + -H "Authorization: Token " \ + -H "Content-Type: application/json" +``` + +**Response:** +```json +{ + "token": "abc123xyz789...", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics", + "expires_in": 300 +} +``` + +**نکته:** URL ثابت است و شامل `token` و `slug` دوره می‌باشد. + +### مرحله 2: تبدیل توکن موقت + +```bash +curl -X POST http://localhost:8000/api/account/exchange-token/ \ + -H "Content-Type: application/json" \ + -d '{"temp_token": "abc123xyz"}' +``` + +**Response:** +```json +{ + "success": true, + "message": "ورود موفق", + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "user": { + "id": 123, + "fullname": "علی احمدی", + "email": "user@example.com", + "avatar": "https://cdn.example.com/avatar.jpg" + } +} +``` + +--- + +## 🔐 امنیت + +1. **One-Time Use**: توکن بعد از استفاده حذف می‌شود +2. **TTL**: توکن پس از 300 ثانیه (5 دقیقه) منقضی می‌شود +3. **Role Detection**: نقش کاربر (admin/user) بر اساس course و permissions تشخیص داده می‌شود + +--- + +## 📋 فایل‌های پیاده‌سازی شده + +1. **Serializer**: `/apps/account/serializers/auth.py` + - اضافه شد: `ExchangeTokenSerializer` + +2. **View**: `/apps/account/views/auth.py` + - اضافه شد: `ExchangeTokenAPIView` + +3. **URL**: `/apps/account/urls.py` + - اضافه شد: `path('exchange-token/', ...)` + +--- + +## 🔄 فلوی کامل + +``` +📱 Mobile App + ↓ +POST /api/courses/1/online/token/ +Headers: Authorization: Token + ↓ +Response: { + "token": "abc123", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123&slug=python-basics" +} + ↓ +📱 Open URL in Browser + ↓ +🌐 Frontend: /join-class?token=abc123&slug=python-basics + ↓ +POST /api/account/exchange-token/ +Body: {"temp_token": "abc123"} + ↓ +Response: { + "token": "a1b2c3...", + "user": {...} +} + ↓ +Save token + user in localStorage + ↓ +Redirect to /online-classroom (with slug if needed) +``` + +--- + +## ✨ ویژگی‌ها + +✅ **Token Management**: استفاده از Redis برای ذخیره‌سازی موقت +✅ **Auth Token**: برگرداندن DRF Token واقعی برای احراز هویت +✅ **Security**: One-time use و TTL محدود +✅ **Error Handling**: مدیریت کامل خطاها +✅ **Avatar Support**: برگرداندن URL avatar کاربر +✅ **Documentation**: Swagger/OpenAPI documentation + +--- + +## 🛠️ نکات پیاده‌سازی + +1. از `OnlineClassTokenManager` استفاده می‌شود که در `utils/redis.py` تعریف شده +2. توکن موقت پس از استفاده با `manager.delete_token()` حذف می‌شود +3. نقش کاربر با بررسی `user.can_manage_course(course)` تشخیص داده می‌شود +4. در صورت عدم وجود course_id، فقط `user.is_staff` بررسی می‌شود + +--- + +## 📱 مثال Flutter + +```dart +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:url_launcher/url_launcher.dart'; + +class OnlineClassService { + final String baseUrl = 'https://your-backend.com'; + + /// مرحله 1: دریافت توکن موقت از بکند + Future> getTemporaryToken({ + required int courseId, + required String userToken, + }) async { + final response = await http.post( + Uri.parse('$baseUrl/courses/$courseId/online/token/'), + headers: { + 'Authorization': 'Token $userToken', + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode == 201) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to get temporary token'); + } + } + + /// مرحله 2: باز کردن لینک در مرورگر + Future openOnlineClass({ + required int courseId, + required String userToken, + }) async { + try { + // دریافت توکن موقت + final tokenData = await getTemporaryToken( + courseId: courseId, + userToken: userToken, + ); + + final joinUrl = tokenData['url'] as String; + + // باز کردن در مرورگر + final uri = Uri.parse(joinUrl); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } else { + throw Exception('Could not launch $joinUrl'); + } + } catch (e) { + print('Error opening online class: $e'); + rethrow; + } + } +} + +// استفاده در UI +class CourseDetailScreen extends StatelessWidget { + final int courseId; + final String userToken; + final OnlineClassService _service = OnlineClassService(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + try { + await _service.openOnlineClass( + courseId: courseId, + userToken: userToken, + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ورود به کلاس: $e')), + ); + } + }, + child: Text('ورود به کلاس آنلاین'), + ), + ), + ); + } +} +``` + +### نصب dependencies برای Flutter + +در `pubspec.yaml`: + +```yaml +dependencies: + http: ^1.1.0 + url_launcher: ^6.2.1 +``` + +--- + +## 🌐 مثال Frontend (Next.js) + +صفحه `/app/join-class/page.tsx`: + +```typescript +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { api } from '@/lib/api'; + +export default function JoinClassPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [error, setError] = useState(null); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + setError('توکن یافت نشد'); + return; + } + + // تبدیل توکن به اطلاعات کاربر + exchangeToken(token) + .then((data) => { + // ذخیره token و user در localStorage + localStorage.setItem('authToken', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + + // هدایت به صفحه کلاس + router.push('/online-classroom'); + }) + .catch((err) => { + setError(err.message || 'خطا در ورود'); + }); + }, [searchParams, router]); + + async function exchangeToken(tempToken: string) { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/account/exchange-token/`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ temp_token: tempToken }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to exchange token'); + } + + const data = await response.json(); + return data; // Returns { success, message, token, user } + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+
در حال ورود...
+
+ ); +} +``` + + diff --git a/docs/hadisdetail.md b/docs/hadisdetail.md new file mode 100644 index 0000000..e9d2c7b --- /dev/null +++ b/docs/hadisdetail.md @@ -0,0 +1,130 @@ +```json +{ + "id": 1800, + "number": 1, + "title": "Достоинство молитвы и ее место в религии - Толкование суры Аль-Фатиха (1)", + "title_narrator": null, + "text": "قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله.\n\nوالصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام.\n\nإن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.", + "translation": "The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted.\n\nPrayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah's religion lightly, and has no share in Islam.\n\nIndeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.", + "explanation": "Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов:\n\nВо-первых, молитва описывается как \"столп религии\" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы.\n\nВо-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение.\n\nВ-третьих, молитва представлена как \"معراج المؤمن\" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему.\n\nХадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.", + "address": null, + "hadis_status_text": null, + "links": [ + { + "link": "https://example.com/source1", + "title": "Source 1" + }, + { + "link": "https://example.com/source2", + "title": "Source 2" + } + ], + "share_link": "https://imamjavad.nwhco.ir/hadis/None", + "status": true, + "category": { + "id": 330, + "title": "Толкование суры Аль-Фатиха", + "category_type": null + }, + "hadis_status": { + "id": 130, + "title": "Прерванный", + "color": "orange" + }, + "tags": [ + { + "id": 510, + "title": "Хадж" + }, + { + "id": 514, + "title": "Терпение" + }, + { + "id": 520, + "title": "Постановления" + } + ], + "transmitters": [ + { + "id": 5992, + "transmitter": { + "id": 53, + "full_name": "Мухаммад ибн аль-Хасан ат-Туси", + "birth_year_hijri": 385, + "death_year_hijri": 460, + "description": "Шейх Туси, автор книг Тахзиб аль-Ахкам и аль-Истибсар", + "reliability": "unknown", + "madhhab": "unknown" + }, + "order": 1, + "is_gap": false + }, + { + "id": 5993, + "transmitter": { + "id": 60, + "full_name": "Мухаммад ибн Муслим", + "birth_year_hijri": 70, + "death_year_hijri": 150, + "description": "Мухаммад ибн Муслим, из сподвижников имама Бакира и имама Садика (мир им)", + "reliability": "unknown", + "madhhab": "unknown" + }, + "order": 2, + "is_gap": false + }, + { + "id": 5994, + "transmitter": { + "id": 56, + "full_name": "Абу Дауд ас-Сиджистани", + "birth_year_hijri": 202, + "death_year_hijri": 275, + "description": "Имам Абу Дауд, автор Сунан Абу Дауд", + "reliability": "unknown", + "madhhab": "unknown" + }, + "order": 3, + "is_gap": false + }, + { + "id": 5995, + "transmitter": { + "id": 59, + "full_name": "Али ибн аль-Хусейн ас-Саджжад", + "birth_year_hijri": 38, + "death_year_hijri": 95, + "description": "Имам Али ибн аль-Хусейн (мир ему), четвертый имам шиитов", + "reliability": "unknown", + "madhhab": "unknown" + }, + "order": 4, + "is_gap": true + }, + { + "id": 5996, + "transmitter": { + "id": 58, + "full_name": "Мухаммад ибн Али аль-Бакир", + "birth_year_hijri": 57, + "death_year_hijri": 114, + "description": "Имам Мухаммад Бакир (мир ему), пятый имам шиитов", + "reliability": "unknown", + "madhhab": "unknown" + }, + "order": 5, + "is_gap": false + } + ], + "description": null, + "references": [ + { + "id": 2193, + "book_title": null, + "book_images": null, + "book_authors": null + } + ] +} +``` \ No newline at end of file diff --git a/docs/live-session-api.md b/docs/live-session-api.md new file mode 100644 index 0000000..3b6790d --- /dev/null +++ b/docs/live-session-api.md @@ -0,0 +1,472 @@ +# راهنمای گرفتن توکن و ورود کلاینت به کلاس‌های plugNmeet + +این راهنما خلاصه می‌کند که برای سناریوی استاد/دانشجو چگونه از سرویس plugNmeet توکن بگیریم و کلاینت فرانت‌اند (`client/`) با آن وارد کلاس شود. + +## پیش‌نیازها +- آدرس سرویس: `window.PLUG_N_MEET_SERVER_URL = "https://meet.newhorizonco.uk"` (در `config.js`). +- `api_key` و `secret` از فایل پیکربندی بک‌اند (`services/plugnmeet-server/config.yaml`). +- بدنهٔ درخواست‌ها باید با پروتکل JSON متناظر با پیام‌های پروتوباف (`plugnmeet-protocol`) ارسال شود؛ سرور طبق `HandleAuthHeaderCheck` هدرهای امنیتی را بررسی می‌کند. + +## گام ۱: ایجاد یا فعال بودن اتاق + +### API Endpoint برای Django Backend: +``` +POST /api/courses//online/room/create/ +``` + +### بدنه درخواست از فرانت به Django: +```json +{ + "subject": "کلاس جبر فصل ۱" // اختیاری - عنوان روم +} +``` + +**⚠️ نکات مهم:** +- **فرانت نباید `metadata` ارسال کند!** +- بک‌اند Django (در `apps/course/views/live_session.py`) به‌طور خودکار تنظیمات امنیتی را اعمال می‌کند +- این تضمین می‌کند که تنظیمات امنیتی به‌صورت متمرکز و یکسان اعمال شود + +**🎯 تنظیمات ضروری برای نمایش فیچرها:** +- برای نمایش **Whiteboard**: باید `whiteboardFeatures.allowedWhiteboard: true` باشد +- برای نمایش **SharedNotePad**: باید `sharedNotePadFeatures.allowedSharedNotePad: true` باشد و Etherpad service فعال باشد +- برای نمایش **BreakoutRoom**: باید `breakoutRoomFeatures.isAllow: true` باشد (فقط در منوی admin) + +### بدنه درخواست از Django به PlugNMeet (خودکار): +بک‌اند Django این بدنه را خودش به PlugNMeet ارسال می‌کند: + +**⚠️ توجه به نامگذاری:** +- در Python می‌توانید از `snake_case` استفاده کنید +- اما **حتماً قبل از ارسال به PlugNMeet API** باید به `camelCase` تبدیل شود +- مثال: `default_lock_settings` → `defaultLockSettings` +- مثال: `room_features` → `roomFeatures` + +```json +{ + "room_id": "algebra-1402", + "metadata": { + "room_title": "کلاس جبر فصل ۱", + "defaultLockSettings": { + "lockMicrophone": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند + "lockWebcam": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند + "lockScreenSharing": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند + "lockWhiteboard": false, // ✅ همه می‌توانند ویرایش کنند + "lockSharedNotepad": false, // ✅ همه می‌توانند ویرایش کنند + "lockChat": false, + "lockChatSendMessage": false, + "lockChatFileShare": false, + "lockPrivateChat": false + }, + "roomFeatures": { + "allowWebcams": true, + "muteOnStart": true, // 🔇 همه با میک خاموش وارد می‌شوند + "allowScreenSharing": true, + "allowRecording": true, + "allowRtmp": false, + "allowViewOtherWebcams": true, + "allowViewOtherParticipantsList": true, + "adminOnlyWebcams": false, + "allowPolls": true, + "roomDuration": 0, + "chatFeatures": { + "allowChat": true, + "allowFileUpload": true + }, + "sharedNotePadFeatures": { + "allowedSharedNotePad": true + }, + "whiteboardFeatures": { + "allowedWhiteboard": true + }, + "breakoutRoomFeatures": { + "isAllow": true, + "allowedNumberRooms": 6 + }, + "waitingRoomFeatures": { + "isActive": false + }, + "recordingFeatures": { + "isAllow": true, + "isAllowCloud": true, + "enableAutoCloudRecording": false + } + } + } +} +``` + +> **چرا بک‌اند این کار را می‌کند؟** +> - ✅ **امنیت متمرکز**: تنظیمات امنیتی در یک جا کنترل می‌شود +> - ✅ **جلوگیری از دستکاری**: فرانت نمی‌تواند تنظیمات را تغییر دهد +> - ✅ **یکپارچگی**: همه کلاس‌ها با تنظیمات یکسان ساخته می‌شوند +> - 🔒 طبق تابع `AssignLockSettingsToUser` در `pkg/models/user_lock.go` این مقادیر برای کاربران غیر-admin اعمال می‌شود + +## گام ۲: گرفتن توکن ورود + +### API Endpoint برای Django Backend: +``` +POST /api/courses/online/room/token/ +``` + +### درخواست از فرانت به Django: +``` +Headers: + Authorization: Token + Content-Type: application/json + +Body: +{ + "course_slug": "algebra-10" +} +``` + +**⚠️ نکات مهم:** +- **فرانت فقط `course_slug` ارسال می‌کند!** +- بک‌اند Django از `Authorization` header کاربر را شناسایی می‌کند +- بک‌اند خودش live session فعال دوره را پیدا می‌کند: + ```python + # 1. پیدا کردن دوره + course = Course.objects.get(slug=course_slug) + + # 2. پیدا کردن live session فعال + session = CourseLiveSession.objects.get( + course=course, + ended_at__isnull=True # session هایی که هنوز به پایان نرسیده‌اند + ) + + # 3. گرفتن room_id + room_id = session.room_id + ``` +- بک‌اند خودش همه اطلاعات کاربر را می‌سازد: + - `user_id` از `request.user` + - `name` از `user.get_full_name()` یا `user.email` + - `is_admin` از `user.can_manage_course(course)` + - `profilePic` از `user.avatar` + - `lock_settings` برای غیر-admin + +### بدنه درخواست از Django به PlugNMeet (خودکار): + +بک‌اند Django این payload را خودش می‌سازد و به PlugNMeet می‌فرستد: + +**برای استاد:** +```json +{ + "room_id": "algebra-1402", + "user_info": { + "user_id": "10", // 🔐 از request.user + "name": "استاد نمونه", // 🔐 از user.get_full_name() + "is_admin": true, // 🔐 از user.can_manage_course() + "user_metadata": { + "is_hidden": false, + "profilePic": "https://..." // 🔐 از user.avatar + } + } +} +``` + +**برای دانشجو:** +```json +{ + "room_id": "algebra-1402", + "user_info": { + "user_id": "27", // 🔐 از request.user + "name": "دانشجو نمونه", // 🔐 از user.get_full_name() + "is_admin": false, // 🔐 از user.can_manage_course() + "user_metadata": { + "profilePic": "https://...", // 🔐 از user.avatar + "lock_settings": { // 🔒 خودکار برای غیر-admin + "lock_microphone": true, + "lock_screen_sharing": true, + "lock_webcam": true, + "lock_whiteboard": false, // ✅ می‌تواند روی whiteboard بنویسد + "lock_shared_notepad": false, // ✅ می‌تواند در notepad بنویسد + "lock_chat": false, + "lock_chat_send_message": false, + "lock_chat_file_share": false, + "lock_private_chat": false + } + } + } +} +``` + +### نحوه کار بک‌اند Django: +```python +# 1. شناسایی کاربر از token +user = request.user # از Authorization header + +# 2. پیدا کردن دوره و session فعال +course = Course.objects.get(slug=course_slug) +session = CourseLiveSession.objects.get(course=course, ended_at__isnull=True) +room_id = session.room_id + +# 3. تشخیص نقش +is_admin = user.can_manage_course(course) # استاد یا مالک دوره + +# 4. ساخت user_info +user_info = { + 'user_id': str(user.id), + 'name': user.get_full_name() or user.email, + 'is_admin': is_admin, +} + +# 4. اضافه کردن profilePic +profile_pic = request.build_absolute_uri(user.avatar.url) +user_metadata['profilePic'] = profile_pic + +# 5. اضافه کردن lock_settings برای غیر-admin +if not is_admin: + user_metadata['lock_settings'] = { + 'lock_microphone': True, + 'lock_screen_sharing': True, + 'lock_webcam': True, + 'lock_whiteboard': False, # دانشجو می‌تواند روی whiteboard بنویسد + 'lock_shared_notepad': False, # دانشجو می‌تواند در notepad بنویسد + 'lock_chat': False, + 'lock_chat_send_message': False, + 'lock_chat_file_share': False, + 'lock_private_chat': False, + } +``` + +### ارسال به PlugNMeet: +بک‌اند Django با هدرهای امنیتی به PlugNMeet ارسال می‌کند: +- `API-KEY`: از settings +- `HASH-SIGNATURE`: `HMAC_SHA256(body, secret)` +- این توکن JWT اختصاصی plugNmeet است که در `GeneratePNMJoinToken` ساخته می‌شود +- `is_admin: true` باعث می‌شود در `GetPNMJoinToken` کاربر به عنوان presenter با تمام دسترسی‌ها ثبت شود +- `lock_settings` باعث می‌شود در فرانت‌اند PlugNMeet دکمه‌های میکروفون/وبکم غیرفعال شوند + +### پاسخ Django به فرانت: +```json +{ + "room_id": "algebra-1402", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "plugnmeet": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires": 300, + ... + } +} +``` + +فرانت با این `token` می‌تواند کاربر را به PlugNMeet وارد کند: +``` +https://meet.newhorizonco.uk/?access_token= +``` + +## گام ۳: ورود کلاینت با توکن +۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئری‌استرینگ یا از کوکی `pnm_access_token` می‌خواند (`getAccessToken` در `client/src/helpers/utils.ts`). +۲. آدرس ورود: `https://meet.newhorizonco.uk/?access_token=`. +۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری: + - درخواست `POST /api/verifyToken` را با هدر `Authorization: ` می‌فرستد (`HandleVerifyToken`). + - اگر توکن معتبر باشد، لیست آدرس‌های NATS و موضوعات لازم را می‌گیرد و اتصال را آغاز می‌کند (`startNatsConn`). +۴. پس از اتصال، وضعیت کاربر و اتاق در Redux ذخیره می‌شود (`sessionSlice`). اگر کاربر ادمین باشد، تمام امکانات بدون محدودیت فعال است؛ در غیر این صورت مقدارهای `lock_settings` تعیین می‌کنند چه دکمه‌هایی فعال باشند. + +## کنترل حالت صحبت/شنیدن برای استاد و دانشجو + +### استاد (Moderator/Host): +- ✅ در توکن `is_admin: true` ارسال می‌شود +- ✅ بک‌اند Django در `apps/course/views/live_session.py` این را تشخیص می‌دهد: + ```python + is_admin = user.can_manage_course(course) # استاد یا مالک دوره + ``` +- ✅ سرور PlugNMeet در `GetPNMJoinToken` رول presenter را فعال می‌کند +- ✅ **هیچ قفلی** روی میکروفون، وبکم یا اشتراک صفحه اعمال نمی‌شود +- 🎤 استاد می‌تواند بلافاصله صحبت کند و به دانشجو **اجازه صحبت** دهد + +### دانشجو (Participant): +- 🔒 در توکن `is_admin: false` ارسال می‌شود +- 🔒 بک‌اند Django خودکار lock_settings را اضافه می‌کند: + ```python + if not is_admin: + user_metadata['lock_settings'] = { + 'lock_microphone': True, + 'lock_screen_sharing': True, + 'lock_webcam': True, + 'lock_whiteboard': False, # می‌تواند روی whiteboard بنویسد + 'lock_shared_notepad': False, # می‌تواند در notepad بنویسد + 'lock_chat': False, + 'lock_chat_send_message': False, + 'lock_chat_file_share': False, + 'lock_private_chat': False, + } + ``` +- 🔇 دکمه‌های میکروفون، وبکم و اشتراک صفحه **غیرفعال** هستند +- 👂 فقط می‌تواند **گوش دهد** تا میزبان اجازه دهد +- ✅ اما می‌تواند در **Whiteboard** و **SharedNotePad** بنویسد و چت کند +- این منطق در `joinModal.tsx` با متغیر `isMicLock` پیاده‌سازی شده است + +### نحوه دادن اجازه به دانشجو: +- میزبان باید از داخل کلاس از طریق UI کنترل کند +- یا از API `/api/updateLockSettings` یا `switchPresenter` استفاده کند + +## نکات تکمیلی + +### توکن‌ها و انقضا: +- توکن‌ها زمان انقضای مفهومی دارند (`client.token_validity` در YAML) +- در صورت نزدیک شدن به انقضا، کلاینت خودکار با `REQ_RENEW_PNM_TOKEN` درخواست تمدید می‌دهد + +### Authorization: +- برای درخواست‌های بعدی به `/api/...` همان هدر `Authorization` را ست کنید +- کلاینت این کار را در `helpers/api/plugNmeetAPI.ts` انجام می‌دهد + +### مدیریت دسترسی‌ها: +- اگر می‌خواهید دانشجو را به صحبت‌کننده ارتقا دهید: `/api/updateLockSettings` یا `switchPresenter` +- این کار فقط توسط **میزبان** امکان‌پذیر است + +## 🔐 جمع‌بندی امنیت + +### ❌ چیزهایی که فرانت نباید انجام دهد: + +#### موقع ساخت روم: +- ❌ ارسال `metadata` +- ❌ ارسال `default_lock_settings` +- ❌ ارسال `room_features` + +#### موقع گرفتن توکن: +- ❌ ارسال `room_id` (بک‌اند خودش از session فعال می‌گیرد) +- ❌ ارسال `user_info` +- ❌ ارسال `is_admin` +- ❌ ارسال `lock_settings` +- ❌ ارسال `user_id` یا `name` + +### ✅ چیزهایی که فرانت فقط ارسال می‌کند: + +#### موقع ساخت روم: +```json +{ + "room_id": "algebra-1402", // اختیاری + "subject": "کلاس جبر" // اختیاری +} +``` + +#### موقع گرفتن توکن: +```json +{ + "course_slug": "algebra-10" // فقط این! +} +``` ++ `Authorization: Token ` در header + +### ✅ چیزهایی که بک‌اند Django خودش انجام می‌دهد: + +#### برای همه درخواست‌ها: +- ✅ شناسایی کاربر از `Authorization` header +- ✅ بررسی دسترسی با `user.can_manage_course()` یا `Participant.objects.filter()` + +#### موقع ساخت روم: +- ✅ تعیین `defaultLockSettings` (همه `true` به جز whiteboard/notepad) +- ✅ تعیین `roomFeatures` **کامل** شامل: + - ✅ `sharedNotePadFeatures.allowedSharedNotePad: true` + - ✅ `whiteboardFeatures.allowedWhiteboard: true` + - ✅ `breakoutRoomFeatures.isAllow: true` + - ✅ `chatFeatures`, `recordingFeatures`, و سایر فیچرها +- ✅ تبدیل نام‌های `snake_case` به `camelCase` قبل از ارسال به PlugNMeet +- ✅ ساخت `metadata` کامل برای PlugNMeet + +#### موقع گرفتن توکن: +- ✅ پیدا کردن live session فعال از `course_slug` +- ✅ گرفتن `room_id` از session +- ✅ ساخت `user_id` از `request.user.id` +- ✅ ساخت `name` از `user.get_full_name()` یا `user.email` +- ✅ تشخیص `is_admin` از `user.can_manage_course(course)` +- ✅ گرفتن `profilePic` از `user.avatar` +- ✅ اضافه کردن `lock_settings` کامل برای غیر-admin شامل: + - ✅ `lock_microphone`, `lock_webcam`, `lock_screen_sharing` (همه `True`) + - ✅ `lock_whiteboard`, `lock_shared_notepad` (همه `False` - می‌توانند بنویسند) + - ✅ `lock_chat`, `lock_chat_send_message`, `lock_private_chat` (همه `False`) +- ✅ تبدیل نام‌های `snake_case` به `camelCase` قبل از ارسال +- ✅ ساخت `user_info` کامل برای PlugNMeet + +**نتیجه:** +- 🔒 **امنیت کامل**: فرانت نمی‌تواند هیچ تنظیمات امنیتی را دستکاری کند +- ✅ **متمرکز**: همه logic در بک‌اند Django است +- 🎯 **ساده**: فرانت فقط `course_slug` و `Authorization` header ارسال می‌کند +- 🔐 **قابل کنترل**: بک‌اند تعیین می‌کند کدام session فعال است + +--- + +## 🐛 عیب‌یابی + +### مشکل: Whiteboard/SharedNotePad نمایش داده نمی‌شود + +**علائم:** +- آیکون Whiteboard در footer نمایش داده نمی‌شود +- گزینه Enable/Disable SharedNotePad در منوی admin نیست +- گزینه Manage Breakout Room در منوی admin نیست + +**راه حل‌ها:** + +1. **بررسی `roomFeatures` در room creation:** + ```json + "roomFeatures": { + "sharedNotePadFeatures": { + "allowedSharedNotePad": true // ✅ باید true باشد + }, + "whiteboardFeatures": { + "allowedWhiteboard": true // ✅ باید true باشد + }, + "breakoutRoomFeatures": { + "isAllow": true // ✅ باید true باشد + } + } + ``` + +2. **بررسی نامگذاری فیلدها:** + - ❌ `shared_note_pad_features` (snake_case) - اشتباه + - ✅ `sharedNotePadFeatures` (camelCase) - صحیح + +3. **بررسی `config.yaml` در plugnmeet-server:** + ```yaml + shared_notepad: + enabled: true # ✅ باید true باشد + etherpad_hosts: + - id: "etherpad_node_01" + host: "http://plugnmeet-etherpad:9001" + client_id: "plugNmeet" + client_secret: "..." + ``` + +4. **بررسی Etherpad service:** + ```bash + docker ps | grep etherpad + # باید یک container با نام plugnmeet-etherpad اجرا باشد + ``` + +5. **بررسی `defaultLockSettings`:** + - اگر `lockWhiteboard: true` باشد، فقط admin می‌تواند ویرایش کند + - اگر `lockSharedNotepad: true` باشد، فقط admin می‌تواند ویرایش کند + +6. **بررسی user `lock_settings` در توکن:** + ```json + "lock_settings": { + "lock_whiteboard": false, // false = می‌تواند ویرایش کند + "lock_shared_notepad": false // false = می‌تواند ویرایش کند + } + ``` + +### مشکل: دانشجو نمی‌تواند در Whiteboard بنویسد + +**علت:** +- `lock_whiteboard: true` در توکن کاربر + +**راه حل:** +- در هنگام ساخت توکن برای دانشجو، `lock_whiteboard` را `false` کنید +- یا از منوی admin، lock را برای آن کاربر خاص باز کنید + +### مشکل: SharedNotePad آیکون دارد اما باز نمی‌شود + +**علت:** +- Etherpad service اجرا نیست یا در دسترس نیست + +**راه حل:** +```bash +# بررسی وضعیت Etherpad +docker-compose -f docker-compose.plugnmeet.yml ps etherpad + +# اگر اجرا نیست، راه‌اندازی کنید +docker-compose -f docker-compose.plugnmeet.yml up -d plugnmeet-etherpad + +# بررسی logs +docker-compose -f docker-compose.plugnmeet.yml logs -f plugnmeet-etherpad +``` diff --git a/docs/live-session-logs.md b/docs/live-session-logs.md new file mode 100644 index 0000000..52a7efa --- /dev/null +++ b/docs/live-session-logs.md @@ -0,0 +1,361 @@ +# راهنمای لاگ‌های Live Session API + +این مستند توضیح می‌دهد که هر API چه لاگ‌هایی تولید می‌کند و چگونه می‌توان از آنها برای debug استفاده کرد. + +## 📋 فرمت لاگ‌ها + +همه لاگ‌ها با یک prefix مشخص شروع می‌شوند: +- `[LiveSession Create]` - مربوط به ساخت روم +- `[LiveSession Token]` - مربوط به گرفتن توکن ورود +- `[Online Validate]` - مربوط به اعتبارسنجی وضعیت دوره + +## 🔹 API: ساخت روم (Create Room) + +**Endpoint:** `POST /api/courses//online/room/create/` + +### جریان لاگ‌ها: + +1. **شروع درخواست:** + ``` + INFO [LiveSession Create] Request from user_id=10 for course=algebra-10 + ``` + +2. **بررسی دسترسی:** + - **موفق:** + ``` + INFO [LiveSession Create] Permission granted for user_id=10 course=algebra-10 + ``` + - **رد شده:** + ``` + WARNING [LiveSession Create] Permission denied - user_id=27 course=algebra-10 + ``` + +3. **فراخوانی PlugNMeet:** + ``` + INFO [LiveSession Create] Calling PlugNMeet API - room_id=algebra-10-20231014102530 course=algebra-10 + ``` + +4. **نتیجه PlugNMeet:** + - **موفق:** + ``` + INFO [LiveSession Create] PlugNMeet room created successfully - room_id=algebra-10-20231014102530 + ``` + - **خطای پیکربندی:** + ``` + ERROR [LiveSession Create] Configuration error - PLUGNMEET_API_KEY is not configured + ``` + - **خطای API:** + ``` + ERROR [LiveSession Create] PlugNMeet API error - room_id=algebra-10-20231014102530 error=Room already exists + ``` + +5. **ذخیره در دیتابیس:** + - **Session جدید:** + ``` + INFO [LiveSession Create] New session created - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 + ``` + - **Session موجود:** + ``` + INFO [LiveSession Create] Existing session reactivated - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 + INFO [LiveSession Create] Session updated - session_id=7 fields=['subject', 'started_at'] + ``` + +6. **نتیجه نهایی:** + ``` + INFO [LiveSession Create] Success - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 user_id=10 + ``` + +### سناریوهای خطا: + +| خطا | لاگ | دلیل | +|-----|-----|------| +| Permission Denied | `WARNING [LiveSession Create] Permission denied - user_id=27 course=algebra-10` | کاربر استاد نیست | +| Configuration Error | `ERROR [LiveSession Create] Configuration error - ...` | تنظیمات PlugNMeet ناقص | +| PlugNMeet API Error | `ERROR [LiveSession Create] PlugNMeet API error - ...` | سرویس PlugNMeet پاسخ خطا داده | + +--- + +## 🔹 API: گرفتن توکن (Get Join Token) + +**Endpoint:** `POST /api/courses/online/room/token/` + +### جریان لاگ‌ها: + +1. **شروع درخواست:** + ``` + INFO [LiveSession Token] Request from user_id=27 for course=algebra-10 + ``` + +2. **پیدا کردن دوره:** + - **موفق:** + ``` + (بدون لاگ - به مرحله بعد می‌رود) + ``` + - **پیدا نشد:** + ``` + WARNING [LiveSession Token] Course not found - course=algebra-10 user_id=27 + ``` + +3. **بررسی تنظیمات دوره:** + - **دوره آنلاین نیست:** + ``` + WARNING [LiveSession Token] Course not configured for online - course=algebra-10 user_id=27 + ``` + +4. **پیدا کردن Session فعال:** + - **موفق:** + ``` + INFO [LiveSession Token] Active session found - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 + ``` + - **پیدا نشد:** + ``` + WARNING [LiveSession Token] No active session found - course=algebra-10 user_id=27 + ``` + +5. **تشخیص نقش کاربر:** + ``` + INFO [LiveSession Token] User role determined - user_id=27 role=student course=algebra-10 + ``` + یا + ``` + INFO [LiveSession Token] User role determined - user_id=10 role=professor course=algebra-10 + ``` + +6. **بررسی دسترسی (برای دانشجو):** + - **ثبت‌نام نشده:** + ``` + WARNING [LiveSession Token] Access denied - user_id=27 not enrolled in course=algebra-10 + ``` + +7. **درخواست توکن از PlugNMeet:** + ``` + INFO [LiveSession Token] Requesting token from PlugNMeet - room_id=algebra-10-20231014102530 user_id=27 role=student + ``` + +8. **نتیجه PlugNMeet:** + - **موفق:** + ``` + INFO [LiveSession Token] Token generated successfully - room_id=algebra-10-20231014102530 user_id=27 + ``` + - **خطا:** + ``` + ERROR [LiveSession Token] Configuration error - PLUGNMEET_API_KEY is not configured + ``` + یا + ``` + ERROR [LiveSession Token] PlugNMeet API error - room_id=algebra-10-20231014102530 user_id=27 error=Room not found + ``` + +9. **نتیجه نهایی:** + ``` + INFO [LiveSession Token] Success - room_id=algebra-10-20231014102530 user_id=27 role=student course=algebra-10 + ``` + +### سناریوهای خطا: + +| خطا | لاگ | دلیل | +|-----|-----|------| +| Course Not Found | `WARNING [LiveSession Token] Course not found - ...` | slug اشتباه است | +| Course Not Online | `WARNING [LiveSession Token] Course not configured for online - ...` | دوره آنلاین نیست | +| No Active Session | `WARNING [LiveSession Token] No active session found - ...` | هیچ session فعالی وجود ندارد | +| Access Denied | `WARNING [LiveSession Token] Access denied - user_id=X not enrolled in course=Y` | دانشجو در کلاس ثبت‌نام نکرده | +| PlugNMeet Error | `ERROR [LiveSession Token] PlugNMeet API error - ...` | مشکل در سرویس PlugNMeet | + +--- + +## 🔹 API: اعتبارسنجی (Online Validate) + +**Endpoint:** `GET /api/courses//online/validate/` + +### جریان لاگ‌ها: + +1. **شروع درخواست:** + ``` + INFO [Online Validate] Request received + ``` + +2. **Decode توکن:** + - **موفق:** + ``` + INFO [Online Validate] Token decoded successfully + ``` + - **خطا:** + ``` + ERROR [Online Validate] Token decode failed - error=Token has expired + ``` + +3. **بررسی Payload:** + - **نامعتبر:** + ``` + WARNING [Online Validate] Invalid token payload - course_id=None user_id=10 + ``` + +4. **شروع پردازش:** + ``` + INFO [Online Validate] Processing for user_id=10 course_id=42 + ``` + +5. **پیدا کردن دوره:** + ``` + INFO [Online Validate] Course found - slug=algebra-10 is_online=True + ``` + +6. **ساخت Metadata:** + ``` + DEBUG [Online Validate Metadata] user_id=10 course=algebra-10 can_manage=True is_online=True can_join=True + ``` + +7. **نتیجه نهایی:** + ``` + INFO [Online Validate] Success - user_id=10 course=algebra-10 can_create=False can_join=True + ``` + +### سناریوهای خطا: + +| خطا | لاگ | دلیل | +|-----|-----|------| +| Token Decode Error | `ERROR [Online Validate] Token decode failed - ...` | توکن منقضی یا نامعتبر | +| Invalid Payload | `WARNING [Online Validate] Invalid token payload - ...` | payload توکن ناقص است | + +--- + +## 🔍 نحوه استفاده از لاگ‌ها برای Debug + +### 1. بررسی جریان کامل یک درخواست: + +برای دنبال کردن یک درخواست خاص، بر اساس `user_id` یا `course` فیلتر کنید: + +```bash +# برای user خاص +tail -f /var/log/app.log | grep "user_id=27" + +# برای course خاص +tail -f /var/log/app.log | grep "course=algebra-10" + +# برای room خاص +tail -f /var/log/app.log | grep "room_id=algebra-10-20231014102530" +``` + +### 2. پیدا کردن خطاها: + +```bash +# همه خطاهای LiveSession +tail -f /var/log/app.log | grep -E "\[LiveSession (Create|Token)\]" | grep ERROR + +# خطاهای PlugNMeet +tail -f /var/log/app.log | grep "PlugNMeet API error" + +# خطاهای دسترسی +tail -f /var/log/app.log | grep -E "(Permission denied|Access denied)" +``` + +### 3. بررسی وضعیت سیستم: + +```bash +# همه درخواست‌های ساخت روم +tail -f /var/log/app.log | grep "\[LiveSession Create\] Request" + +# همه درخواست‌های توکن +tail -f /var/log/app.log | grep "\[LiveSession Token\] Request" + +# تشخیص نقش کاربران +tail -f /var/log/app.log | grep "User role determined" +``` + +### 4. آمار سریع: + +```bash +# تعداد room های ساخته شده امروز +grep "LiveSession Create.*Success" /var/log/app.log | grep "$(date +%Y-%m-%d)" | wc -l + +# تعداد توکن‌های صادر شده +grep "LiveSession Token.*Success" /var/log/app.log | grep "$(date +%Y-%m-%d)" | wc -l + +# تعداد خطاها +grep "ERROR.*LiveSession" /var/log/app.log | grep "$(date +%Y-%m-%d)" | wc -l +``` + +--- + +## ⚙️ تنظیمات Logging + +برای فعال کردن لاگ‌ها، در `settings.py`: + +```python +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': '/var/log/app.log', + 'formatter': 'verbose', + }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'apps.course.views.live_session': { + 'handlers': ['file', 'console'], + 'level': 'INFO', + 'propagate': False, + }, + 'apps.course.views.course': { + 'handlers': ['file', 'console'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} +``` + +### سطوح لاگ: + +- **INFO**: جریان عادی - شروع درخواست، موفقیت عملیات +- **WARNING**: وضعیت غیرعادی اما قابل کنترل - دسترسی رد شده، دوره پیدا نشد +- **ERROR**: خطاهای سیستم - مشکل PlugNMeet، خطای پیکربندی +- **DEBUG**: جزئیات بیشتر - ساخت metadata، مقادیر داخلی + +برای debug بیشتر، `level` را به `DEBUG` تغییر دهید. + +--- + +## 📊 مثال: جریان کامل یک Session + +``` +INFO [LiveSession Create] Request from user_id=10 for course=algebra-10 +INFO [LiveSession Create] Permission granted for user_id=10 course=algebra-10 +INFO [LiveSession Create] Calling PlugNMeet API - room_id=algebra-10-20231014102530 course=algebra-10 +INFO [LiveSession Create] PlugNMeet room created successfully - room_id=algebra-10-20231014102530 +INFO [LiveSession Create] New session created - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 +INFO [LiveSession Create] Success - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 user_id=10 + +INFO [LiveSession Token] Request from user_id=10 for course=algebra-10 +INFO [LiveSession Token] Active session found - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 +INFO [LiveSession Token] User role determined - user_id=10 role=professor course=algebra-10 +INFO [LiveSession Token] Requesting token from PlugNMeet - room_id=algebra-10-20231014102530 user_id=10 role=professor +INFO [LiveSession Token] Token generated successfully - room_id=algebra-10-20231014102530 user_id=10 +INFO [LiveSession Token] Success - room_id=algebra-10-20231014102530 user_id=10 role=professor course=algebra-10 + +INFO [LiveSession Token] Request from user_id=27 for course=algebra-10 +INFO [LiveSession Token] Active session found - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 +INFO [LiveSession Token] User role determined - user_id=27 role=student course=algebra-10 +INFO [LiveSession Token] Requesting token from PlugNMeet - room_id=algebra-10-20231014102530 user_id=27 role=student +INFO [LiveSession Token] Token generated successfully - room_id=algebra-10-20231014102530 user_id=27 +INFO [LiveSession Token] Success - room_id=algebra-10-20231014102530 user_id=27 role=student course=algebra-10 +``` + +این جریان نشان می‌دهد: +1. استاد روم را ساخت +2. استاد توکن گرفت و وارد شد (role=professor) +3. دانشجو توکن گرفت و وارد شد (role=student) diff --git a/docs/online_class_entry_flow.md b/docs/online_class_entry_flow.md new file mode 100644 index 0000000..c77d25f --- /dev/null +++ b/docs/online_class_entry_flow.md @@ -0,0 +1,152 @@ +# Online Class Entry Scenario + +## 1. دریافت توکن ورود به کلاس آنلاین + +- **هدف**: کاربر لاگین‌شده لینک ورود موقت به کلاس بگیرد. +- **درخواست**: `POST /api/courses/{course_id}/online/token/` به همراه توکن احراز هویت کاربر. +- **ورودی اختیاری**: `redirect_path` (مسیر نسبی در فرانت برای صفحه کلاس). +- **خروجی**: توکن یک‌بارمصرف ذخیره‌شده در Redis + آدرس نهایی ورود (دامنه فرانت + Query Param توکن). + +### نمونه `curl` +```bash +curl --request POST \ + --url https://api.example.com/api/courses/42/online/token/ \ + --header 'Authorization: Token USER_AUTH_TOKEN' \ + --header 'Content-Type: application/json' \ + --data '{ + "redirect_path": "online-classroom" + }' +``` + +### نمونه پاسخ +```json +{ + "token": "5f7b8c...e1", + "url": "https://imamjavad.newhorizonco.uk/join-class?token=5f7b8c...e1&slug=dars-akhlagh", + "expires_in": 300 +} +``` + +**نکته:** URL ثابت است: `https://imamjavad.newhorizonco.uk/join-class?token={TOKEN}&slug={COURSE_SLUG}` + +## 2. اعتبارسنجی توکن و دریافت داده‌های کلاس + +- **هدف**: فرانت با توکن دریافتی اطلاعات کلاس، پروفایل کاربر و متادیتا را بگیرد. +- **درخواست**: `POST /api/courses/online/token/validate/` +- **ورودی**: `token` +- **خروجی**: آبجکت دوره (سریالایز کامل)، پروفایل کاربر، و متادیتا شامل وضعیت کلاس، زمان‌ها و وضعیت حضور استاد. + +### نمونه `curl` +```bash +curl --request POST \ + --url https://api.example.com/api/courses/online/token/validate/ \ + --header 'Content-Type: application/json' \ + --data '{ + "token": "5f7b8c...e1" + }' +``` + +### نمونه پاسخ +```json +{ + "course": { + "id": 42, + "title": "درس اخلاق", + "slug": "dars-akhlagh", + "category": { + "name": "علوم اسلامی", + "slug": "islamic-sciences", + "course_count": 15 + }, + "access": true, + "participant_count": 85, + "professor": { + "id": 7, + "fullname": "استاد رضایی", + "slug": "rezaei", + "avatar": "https://api.example.com/media/avatars/rezaei.jpg", + "email": "rezaei@example.com", + "phone_number": "+1234567890", + "info": "استاد دانشگاه", + "skill": "فقه و اصول", + "city": "قم", + "country": "ایران", + "birthdate": "1975-05-15", + "gender": "male" + }, + "is_professor": false, + "thumbnail": "https://api.example.com/media/courses/akhlagh.jpg", + "video_type": "link", + "video_file": null, + "video_link": "https://example.com/intro-video.mp4", + "is_online": true, + "online_link": "https://meeting.example.com/class/42", + "level": "intermediate", + "description": "دوره جامع اخلاق اسلامی...", + "duration": "3 ماه", + "lessons_count": 12, + "lessons_complated_count": 5, + "short_description": "آشنایی با مبانی اخلاق اسلامی", + "status": "ongoing", + "is_free": false, + "price": "50000.00", + "discount_percentage": 10, + "final_price": "45000.00", + "timing": { + "monday": "18:00", + "wednesday": "18:00" + }, + "features": ["ضبط جلسات", "گواهینامه معتبر", "پشتیبانی 24 ساعته"], + "last_lesson_id": 6, + "room_id": 123, + "user_transaction_status": "approved" + }, + "user": { + "id": 105, + "device_id": "device-xyz-123", + "fcm": "fcm-token-abc", + "fullname": "علی احمدی", + "slug": "ali-ahmadi", + "avatar": "https://api.example.com/media/avatars/ali.jpg", + "email": "ali@example.com", + "phone_number": "+9876543210", + "info": "دانشجو", + "skill": "برنامه‌نویسی", + "city": "تهران", + "country": "ایران", + "birthdate": "1995-08-20", + "gender": "male" + }, + "metadata": { + "status": "ongoing", + "is_online": true, + "has_started": true, + "has_finished": false, + "professor_in_class": false, + "can_start_online_class": false, + "scheduled_times": { + "monday": "18:00", + "wednesday": "18:00" + }, + "generated_at": "2024-10-05T10:15:30Z", + "validated_at": "2024-10-05T10:16:05.123456Z", + "livesession_started_at": "2024-10-05T10:15:30Z", + + } +} +``` + +**توضیحات فیلدهای مهم:** +- **course**: شامل تمام اطلاعات دوره شامل استاد، دسته‌بندی، درس‌ها، قیمت و... +- **user**: پروفایل کامل کاربری که توکن را اعتبارسنجی کرده +- **metadata.can_start_online_class**: `true` برای استاد دوره، `false` برای دانشجویان (فقط استاد می‌تواند کلاس را شروع کند) +- **metadata.professor_in_class**: نشان می‌دهد استاد در حال حاضر در کلاس حضور دارد یا خیر +- **metadata.has_started**: دوره شروع شده است (`ongoing` یا `finished`) +- **metadata.has_finished**: دوره به پایان رسیده است (`finished`) + +## نکات پیاده‌سازی در فرانت‌اند + +1. پس از دریافت پاسخ مرحله‌ٔ اول، کاربر را به `url` بازگردانی کنید. +2. در صفحه کلاس، توکن از Query String خوانده شده و به مرحله‌ٔ دوم ارسال شود. +3. در صورت خطا (Expiry یا Invalid)، فرانت باید کاربر را به صفحه‌ٔ اصلی یا خطا هدایت کند و درخواست توکن جدید بدهد. +4. `expires_in` نشان می‌دهد لینک چه مدت اعتبار دارد؛ بهتر است شمارش معکوس یا تلاش خودکار برای تمدید توکن داشته باشید. diff --git a/docs/plugnmeet_api.md b/docs/plugnmeet_api.md new file mode 100644 index 0000000..50deff3 --- /dev/null +++ b/docs/plugnmeet_api.md @@ -0,0 +1,1660 @@ +# 📚 Plugnmeet Server API Documentation + +> **مستندات کامل API سرور کنفرانس ویدیویی Plugnmeet** + +این مستند راهنمای کامل API های Plugnmeet Server را شامل می‌شود که بر پایه LiveKit ساخته شده است. + +--- + +## 📖 فهرست مطالب + +### 🚀 شروع سریع +- [بررسی فعال بودن روم](#-quick-check-room-status) +- [قابلیت‌های کلیدی](#-core-features) + +### 🔐 احراز هویت و امنیت +- [نحوه احراز هویت](#-authentication) + - [روش `/auth` (HMAC + JSON)](#1-auth-endpoints-hmac--json) + - [روش `/api` (Bearer Token + Protobuf)](#2-api-endpoints-bearer-token--protobuf) + - [روش‌های خاص (LTI & BBB)](#3-special-authentication-methods) + +### 🎯 API Reference +- [**Room Management** - مدیریت اتاق‌ها](#-room-management-api) +- [**Recording Management** - مدیریت ضبط‌ها](#-recording-management-api) +- [**Analytics** - آنالیتیکس و گزارش‌گیری](#-analytics-api) +- [**In-Meeting Controls** - کنترل‌های داخل جلسه](#-in-meeting-controls-api) +- [**Advanced Features** - امکانات پیشرفته](#-advanced-features) + +### 🔧 سایر سرویس‌ها +- [Webhook, Health Check, Downloads](#-other-services) +- [BBB & LTI Compatibility](#-compatibility-apis) + +--- + +## 🚀 Quick Check: Room Status + +ساده‌ترین روش برای بررسی فعال بودن یک روم: + +### Endpoint +```http +POST /auth/room/isRoomActive +``` + +### Request +```json +{ + "roomId": "your-room-id" +} +``` + +### Response +```json +{ + "status": true, + "msg": "room is active", + "isActive": true +} +``` + +### cURL Example +```bash +#!/bin/bash +API_KEY="your-api-key" +SECRET="your-secret-key" +BODY='{"roomId":"algebra-1402"}' + +# محاسبه HMAC-SHA256 +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') + +# ارسال درخواست +curl -X POST 'https://your-domain.com/auth/room/isRoomActive' \ + -H "API-KEY: $API_KEY" \ + -H "HASH-SIGNATURE: $SIG" \ + -H 'Content-Type: application/json' \ + -d "$BODY" +``` + +--- + +## ⭐ Core Features + +Plugnmeet Server مجموعه کاملی از قابلیت‌های حرفه‌ای برای برگزاری کنفرانس‌های آنلاین را فراهم می‌کند: + +### 🎥 Video Conferencing +- ✅ HD Audio/Video با کیفیت بالا +- ✅ Screen Sharing - اشتراک‌گذاری صفحه +- ✅ Virtual Backgrounds - پس‌زمینه مجازی +- ✅ Adaptive Streaming (Simulcast & Dynacast) + +### 📊 Collaboration Tools +- ✅ Interactive Whiteboard با پشتیبانی از فایل‌های PDF/Office +- ✅ Shared Notepad - یادداشت مشترک +- ✅ Live Polls - نظرسنجی زنده +- ✅ Breakout Rooms - اتاق‌های گروهی + +### 🎬 Recording & Streaming +- ✅ Cloud Recording - ضبط ابری با فرمت MP4 +- ✅ RTMP Streaming - پخش زنده +- ✅ Ingress Support (RTMP/WHIP) + +### 🛡️ Security & Control +- ✅ Waiting Room - اتاق انتظار +- ✅ Lock Settings - قفل کردن قابلیت‌ها +- ✅ User Management - مدیریت کاربران +- ✅ End-to-End Encryption + +### 📈 Analytics & Monitoring +- ✅ Session Analytics - آنالیتیکس جلسات +- ✅ Participant Reports - گزارش شرکت‌کنندگان +- ✅ Real-time Monitoring + +### ♿ Accessibility +- ✅ Speech-to-Text - گفتار به متن +- ✅ Real-time Translation (Azure) + +--- + +## 🔐 Authentication + +Plugnmeet از سه روش احراز هویت مختلف استفاده می‌کند: + +### 1. `/auth` Endpoints (HMAC + JSON) + +برای عملیات مدیریتی و ساخت توکن‌ها استفاده می‌شود. + +#### Headers +```http +API-KEY: your_api_key +HASH-SIGNATURE: hmac_sha256_hex_signature +Content-Type: application/json +``` + +#### محاسبه HMAC Signature + +**Bash/Shell:** +```bash +SECRET="your-secret-key" +BODY='{"roomId":"test-room"}' +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') +``` + +**Python:** +```python +import hmac +import hashlib +import json + +secret = "your-secret-key" +body = {"roomId": "test-room"} +body_json = json.dumps(body) + +signature = hmac.new( + secret.encode('utf-8'), + body_json.encode('utf-8'), + hashlib.sha256 +).hexdigest() +``` + +**Node.js:** +```javascript +const crypto = require('crypto'); + +const secret = 'your-secret-key'; +const body = JSON.stringify({ roomId: 'test-room' }); + +const signature = crypto + .createHmac('sha256', secret) + .update(body) + .digest('hex'); +``` + +--- + +### 2. `/api` Endpoints (Bearer Token + Protobuf) + +برای کنترل‌های داخل جلسه استفاده می‌شود. + +#### Headers +```http +Authorization: +Content-Type: application/octet-stream +``` + +> **توکن دسترسی** از طریق `/auth/room/getJoinToken` دریافت می‌شود. + +#### Request/Response Format +- **بدنه درخواست**: Binary Protobuf (استفاده از SDK توصیه می‌شود) +- **پاسخ**: Binary Protobuf + +#### cURL Example با Protobuf +```bash +# ساخت فایل باینری با SDK +# سپس ارسال با curl +curl -X POST 'https://your-domain.com/api/recording' \ + -H "Authorization: $TOKEN" \ + -H 'Content-Type: application/octet-stream' \ + --data-binary @recording_req.bin \ + -o response.bin +``` + +> **نکته**: برخی endpoint های `/api` مانند `convertWhiteboardFile` و `fileUpload` از JSON استفاده می‌کنند. + +--- + +### 3. Special Authentication Methods + +#### LTI (Learning Tools Interoperability) +```http +Authorization: +``` +مسیرها: `/lti/v1/...` + +#### BigBlueButton Compatibility +نیازمند `checksum` محاسبه شده مطابق استاندارد BBB +مسیرها: `/:apiKey/bigbluebutton/api/...` + +--- + +## 📋 Room Management API + +### 🏗️ Create Room + +اتاق جلسه جدید ایجاد می‌کند. + +#### Endpoint +```http +POST /auth/room/create +``` + +#### Request Body +```json +{ + "roomId": "algebra-class-1402", + "maxParticipants": 50, + "emptyTimeout": 300, + "metadata": { + "roomTitle": "کلاس جبر خطی", + "welcomeMessage": "به کلاس جبر خوش آمدید", + "defaultLockSettings": { + "lockMicrophone": false, + "lockWebcam": false, + "lockScreenSharing": true, + "lockChat": false, + "lockChatSendMessage": false, + "lockChatFileShare": false, + "lockPrivateChat": false, + "lockWhiteboard": true, + "lockSharedNotepad": false + }, + "roomFeatures": { + "allowWebcams": true, + "muteOnStart": false, + "allowScreenSharing": true, + "allowRecording": true, + "allowRtmp": true, + "allowViewOtherWebcams": true, + "allowViewOtherParticipantsList": true, + "adminOnlyWebcams": false, + "allowPolls": true, + "roomDuration": 0, + "recordingFeatures": { + "isAllow": true, + "isAllowCloud": true, + "enableAutoCloudRecording": false + }, + "chatFeatures": { + "allowChat": true, + "allowFileUpload": true + }, + "sharedNotePadFeatures": { + "allowedSharedNotePad": true + }, + "whiteboardFeatures": { + "allowedWhiteboard": true, + "preloadFile": "" + }, + "breakoutRoomFeatures": { + "isAllow": true, + "allowedNumberRooms": 6 + }, + "displayExternalLinkFeatures": { + "isAllow": true + }, + "ingressFeatures": { + "isAllow": false + }, + "speechToTextTranslationFeatures": { + "isAllow": true, + "isAllowTranslation": true + } + }, + "webhookUrl": "https://your-domain.com/webhook", + "isBreakoutRoom": false, + "parentRoomId": "" + } +} +``` + +#### Response +```json +{ + "status": true, + "msg": "room created successfully", + "roomId": "algebra-class-1402" +} +``` + +--- + +### 🎫 Generate Join Token + +توکن ورود کاربر به جلسه را ایجاد می‌کند. + +#### Endpoint +```http +POST /auth/room/getJoinToken +``` + +#### Request Body +```json +{ + "roomId": "algebra-class-1402", + "userInfo": { + "userId": "student-123", + "name": "علی احمدی", + "isAdmin": false, + "isHidden": false, + "userMetadata": { + "profilePic": "https://example.com/avatar.jpg", + "lockSettings": { + "lockMicrophone": false, + "lockWebcam": false, + "lockScreenSharing": true, + "lockChat": false + } + } + } +} +``` + +#### Response +```json +{ + "status": true, + "msg": "token generated", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +#### Integration Example +```html + + + + Join Meeting + + + + + +``` + +--- + +### ✅ Check Room Status + +#### Endpoint +```http +POST /auth/room/isRoomActive +``` + +#### Request +```json +{ + "roomId": "algebra-class-1402" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "room is active", + "isActive": true +} +``` + +--- + +### 📊 Get Active Room Info + +اطلاعات کامل یک روم فعال و لیست شرکت‌کنندگان آن را برمی‌گرداند. + +#### Endpoint +```http +POST /auth/room/getActiveRoomInfo +``` + +#### Request +```json +{ + "roomId": "algebra-class-1402" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "room": { + "roomInfo": { + "sid": "RM_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "name": "algebra-class-1402", + "emptyTimeout": 300, + "maxParticipants": 50, + "creationTime": "1699123456", + "metadata": "{...}" + }, + "participantsInfo": [ + { + "sid": "PA_xxxxxxxxxxxx", + "identity": "student-123", + "name": "علی احمدی", + "state": 0, + "joinedAt": "1699123500" + } + ] + } +} +``` + +--- + +### 📋 List All Active Rooms + +لیست تمام روم‌های فعال را برمی‌گرداند. + +#### Endpoint +```http +POST /auth/room/getActiveRoomsInfo +``` + +#### Request +```json +{} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "rooms": [ + { + "roomId": "algebra-class-1402", + "sid": "RM_xxxxxxxxxxxx", + "numParticipants": 15, + "creationTime": "1699123456" + }, + { + "roomId": "physics-class-1402", + "sid": "RM_yyyyyyyyyyyy", + "numParticipants": 8, + "creationTime": "1699123789" + } + ] +} +``` + +--- + +### 🛑 End Room + +جلسه را به پایان می‌رساند و تمام شرکت‌کنندگان را خارج می‌کند. + +#### Endpoint +```http +POST /auth/room/endRoom +``` + +#### Request +```json +{ + "roomId": "algebra-class-1402" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "room ended successfully" +} +``` + +--- + +### 📜 Fetch Past Rooms + +لیست روم‌های گذشته را با امکان فیلتر و صفحه‌بندی برمی‌گرداند. + +#### Endpoint +```http +POST /auth/room/fetchPastRooms +``` + +#### Request +```json +{ + "roomIds": ["algebra-class-1402", "physics-class-1402"], + "from": 0, + "limit": 20, + "orderBy": "DESC" +} +``` + +#### Request Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `roomIds` | `string[]` | لیست شناسه‌های روم (اختیاری) | +| `from` | `number` | شروع صفحه‌بندی | +| `limit` | `number` | تعداد نتایج (حداکثر 100) | +| `orderBy` | `string` | ترتیب: `ASC` یا `DESC` | + +#### Response +```json +{ + "status": true, + "msg": "success", + "result": { + "totalRooms": 45, + "from": 0, + "limit": 20, + "orderBy": "DESC", + "roomsList": [ + { + "roomId": "algebra-class-1402", + "sid": "RM_xxxxxxxxxxxx", + "roomTitle": "کلاس جبر خطی", + "creationTime": "1699123456", + "ended": "1699127056", + "roomDuration": 3600 + } + ] + } +} +``` + +--- + +## 🎬 Recording Management API + +### 📋 Fetch Recordings + +لیست ضبط‌های انجام شده را دریافت می‌کند. + +#### Endpoint +```http +POST /auth/recording/fetch +``` + +#### Request +```json +{ + "roomIds": ["algebra-class-1402"], + "from": 0, + "limit": 20, + "orderBy": "DESC" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "result": { + "totalRecordings": 5, + "from": 0, + "limit": 20, + "orderBy": "DESC", + "recordings": [ + { + "recordId": "rec_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "roomSid": "RM_xxxxxxxxxxxx", + "filePath": "/recordings/algebra-class-1402_20231105.mp4", + "fileSize": 524288000, + "creationTime": "1699123456", + "roomCreationTime": "1699120000", + "recordingDuration": 3600 + } + ] + } +} +``` + +--- + +### 📄 Get Recording Info + +اطلاعات کامل یک ضبط را برمی‌گرداند. + +#### Endpoint +```http +POST /auth/recording/recordingInfo +``` + +#### Request +```json +{ + "recordId": "rec_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "recordingInfo": { + "recordId": "rec_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "filePath": "/recordings/algebra-class-1402_20231105.mp4", + "fileSize": 524288000, + "creationTime": "1699123456" + } +} +``` + +--- + +### 🗑️ Delete Recording + +یک ضبط را حذف می‌کند. + +#### Endpoint +```http +POST /auth/recording/delete +``` + +#### Request +```json +{ + "recordId": "rec_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "recording deleted successfully" +} +``` + +--- + +### 🔗 Get Download Token + +توکن موقت برای دانلود فایل ضبط شده ایجاد می‌کند. + +#### Endpoint +```http +POST /auth/recording/getDownloadToken +``` + +#### Request +```json +{ + "recordId": "rec_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "token generated", + "token": "download_token_xxxxxxxxxxxx" +} +``` + +#### Download File +```bash +# دانلود فایل با توکن +curl -o recording.mp4 \ + "https://your-domain.com/download/recording/download_token_xxxxxxxxxxxx" +``` + +--- + +## 📈 Analytics API + +### 📋 Fetch Analytics + +لیست آنالیتیکس جلسات را دریافت می‌کند. + +#### Endpoint +```http +POST /auth/analytics/fetch +``` + +#### Request +```json +{ + "roomIds": ["algebra-class-1402"], + "from": 0, + "limit": 20 +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "result": { + "totalAnalytics": 10, + "analyticsList": [ + { + "analyticsId": "ana_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "roomSid": "RM_xxxxxxxxxxxx", + "fileId": "file_xxxxxxxxxxxx", + "fileName": "analytics_algebra-class-1402_20231105.json", + "filePath": "/analytics/algebra-class-1402_20231105.json", + "fileSize": 102400, + "creationTime": "1699127056" + } + ] + } +} +``` + +--- + +### 🗑️ Delete Analytics + +#### Endpoint +```http +POST /auth/analytics/delete +``` + +#### Request +```json +{ + "analyticsId": "ana_xxxxxxxxxxxx" +} +``` + +--- + +### 🔗 Get Download Token + +#### Endpoint +```http +POST /auth/analytics/getDownloadToken +``` + +#### Request +```json +{ + "analyticsId": "ana_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "token generated", + "token": "analytics_token_xxxxxxxxxxxx" +} +``` + +#### Download Analytics File +```bash +curl -o analytics.json \ + "https://your-domain.com/download/analytics/analytics_token_xxxxxxxxxxxx" +``` + +--- + +## 🎮 In-Meeting Controls API + +> **نکته مهم**: تمام endpoint های این بخش نیازمند **Bearer Token** در هدر `Authorization` هستند و از **Binary Protobuf** استفاده می‌کنند (مگر در موارد خاص که JSON ذکر شده باشد). + +### 🔐 Verify Token + +توکن کاربر را تایید کرده و اطلاعات اتصال را برمی‌گرداند. + +#### Endpoint +```http +POST /api/verifyToken +``` + +#### Request (Protobuf) +```protobuf +message VerifyTokenReq {} +``` + +#### Response (Protobuf) +```protobuf +message VerifyTokenRes { + bool status = 1; + string msg = 2; + string roomId = 3; + string userId = 4; + string roomSid = 5; + repeated string natsWsUrls = 6; + string natsSubject = 7; + string serverVersion = 8; +} +``` + +--- + +### 🎬 Recording & RTMP Control + +#### Start/Stop Recording + +**Endpoint:** +```http +POST /api/recording +``` + +**Request (Protobuf):** +```protobuf +message RecordingReq { + string sid = 1; // Room SID + RecordingTasks task = 2; // START_RECORDING | STOP_RECORDING + string rtmpUrl = 3; // برای RTMP +} + +enum RecordingTasks { + START_RECORDING = 0; + STOP_RECORDING = 1; + START_RTMP = 2; + STOP_RTMP = 3; +} +``` + +**Response (Protobuf):** +```protobuf +message RecordingRes { + bool status = 1; + string msg = 2; +} +``` + +--- + +### 🛑 End Room + +اتاق را از داخل جلسه به پایان می‌رساند (فقط ادمین). + +#### Endpoint +```http +POST /api/endRoom +``` + +#### Request (Protobuf) +```protobuf +message RoomEndReq { + string roomId = 1; +} +``` + +--- + +### 🔒 Update Lock Settings + +تنظیمات قفل کاربران را تغییر می‌دهد (فقط ادمین). + +#### Endpoint +```http +POST /api/updateLockSettings +``` + +#### Request (Protobuf) +```protobuf +message UpdateUserLockSettingsReq { + string roomSid = 1; + string roomId = 2; + string userId = 3; // "all" برای همه | شناسه کاربر خاص + string service = 4; // mic | webcam | screenShare | chat | etc. + string direction = 5; // "lock" | "unlock" +} +``` + +#### Available Services +- `mic` - میکروفون +- `webcam` - وب‌کم +- `screenShare` - اشتراک‌گذاری صفحه +- `chat` - چت +- `sendChatMsg` - ارسال پیام در چت +- `chatFile` - ارسال فایل در چت +- `privateChat` - چت خصوصی +- `whiteboard` - وایت‌برد +- `sharedNotepad` - یادداشت مشترک + +--- + +### 🔇 Mute/Unmute Track + +میکروفون یک یا تمام کاربران را قطع یا وصل می‌کند (فقط ادمین). + +#### Endpoint +```http +POST /api/muteUnmuteTrack +``` + +#### Request (Protobuf) +```protobuf +message MuteUnMuteTrackReq { + string sid = 1; // Room SID + string roomId = 2; + string userId = 3; // "all" برای همه | شناسه کاربر + string trackSid = 4; // اختیاری + bool muted = 5; // true = mute | false = unmute +} +``` + +--- + +### 👤 Remove Participant + +کاربر را از جلسه حذف می‌کند (فقط ادمین). + +#### Endpoint +```http +POST /api/removeParticipant +``` + +#### Request (Protobuf) +```protobuf +message RemoveParticipantReq { + string sid = 1; + string roomId = 2; + string userId = 3; + string msg = 4; // پیام برای کاربر + bool blockUser = 5; // مسدود کردن دائمی +} +``` + +--- + +### 🎤 Switch Presenter + +نقش ارائه‌دهنده را به کاربر می‌دهد یا می‌گیرد (فقط ادمین). + +#### Endpoint +```http +POST /api/switchPresenter +``` + +#### Request (Protobuf) +```protobuf +message SwitchPresenterReq { + string userId = 1; + SwitchPresenterTask task = 2; // PROMOTE | DEMOTE +} + +enum SwitchPresenterTask { + PROMOTE = 0; + DEMOTE = 1; +} +``` + +--- + +## 🎨 Advanced Features + +### 🔗 External Display Link + +لینک خارجی را برای تمام شرکت‌کنندگان نمایش می‌دهد (فقط ادمین). + +#### Endpoint +```http +POST /api/externalDisplayLink +``` + +#### Request (Protobuf) +```protobuf +message ExternalDisplayLinkReq { + ExternalDisplayLinkTask task = 1; // START_EXTERNAL_LINK | STOP_EXTERNAL_LINK + string url = 2; +} +``` + +--- + +### 🎵 External Media Player + +ویدیو یا صدای خارجی را پخش می‌کند (فقط ادمین). + +#### Endpoint +```http +POST /api/externalMediaPlayer +``` + +#### Request (Protobuf) +```protobuf +message ExternalMediaPlayerReq { + ExternalMediaPlayerTask task = 1; // START_PLAYBACK | STOP_PLAYBACK + string url = 2; + bool isPresentation = 3; +} +``` + +> **نکته**: می‌توانید فایل را با `/api/fileUpload` آپلود کرده و لینک `/download/uploadedFile/...` را استفاده کنید. + +--- + +### 🚪 Waiting Room + +#### Approve Users + +کاربران در اتاق انتظار را تایید می‌کند (فقط ادمین). + +**Endpoint:** +```http +POST /api/waitingRoom/approveUsers +``` + +**Request (Protobuf):** +```protobuf +message ApproveWaitingUsersReq { + repeated string userIds = 1; +} +``` + +#### Update Waiting Room Message + +**Endpoint:** +```http +POST /api/waitingRoom/updateMsg +``` + +**Request (Protobuf):** +```protobuf +message UpdateWaitingRoomMessageReq { + string message = 1; +} +``` + +--- + +### 📊 Polls (نظرسنجی) + +#### Create Poll + +نظرسنجی جدید ایجاد می‌کند (فقط ادمین). + +**Endpoint:** +```http +POST /api/polls/create +``` + +**Request (Protobuf):** +```protobuf +message CreatePollReq { + string question = 1; + repeated PollOption options = 2; + bool isAnonymous = 3; + bool allowMultipleVotes = 4; +} + +message PollOption { + uint64 id = 1; + string text = 2; +} +``` + +--- + +#### List Polls + +**Endpoint:** +```http +GET /api/polls/listPolls +``` + +**Response:** Binary Protobuf + +--- + +#### Submit Poll Response + +**Endpoint:** +```http +POST /api/polls/submitResponse +``` + +**Request (Protobuf):** +```protobuf +message SubmitPollResponseReq { + string pollId = 1; + repeated uint64 selectedOptionIds = 2; +} +``` + +--- + +#### Get Poll Results + +**Endpoint:** +```http +GET /api/polls/pollResponsesResult/:pollId +``` + +**Response:** Binary Protobuf با نتایج نظرسنجی + +--- + +### 🏢 Breakout Rooms (اتاق‌های گروهی) + +#### Create Breakout Rooms + +**Endpoint:** +```http +POST /api/breakoutRoom/create +``` + +**Request (Protobuf):** +```protobuf +message CreateBreakoutRoomsReq { + uint64 duration = 1; + repeated BreakoutRoom rooms = 2; +} + +message BreakoutRoom { + string id = 1; + string title = 2; + repeated string userIds = 3; +} +``` + +--- + +#### Join Breakout Room + +**Endpoint:** +```http +POST /api/breakoutRoom/join +``` + +**Request (Protobuf):** +```protobuf +message JoinBreakoutRoomReq { + string breakoutRoomId = 1; +} +``` + +--- + +#### List Breakout Rooms + +**Endpoint:** +```http +GET /api/breakoutRoom/listRooms +``` + +--- + +#### End Breakout Room + +**Endpoint:** +```http +POST /api/breakoutRoom/endRoom +``` + +**Request (Protobuf):** +```protobuf +message EndBreakoutRoomReq { + string breakoutRoomId = 1; +} +``` + +--- + +#### End All Breakout Rooms + +**Endpoint:** +```http +POST /api/breakoutRoom/endAllRooms +``` + +--- + +### 📡 Ingress (RTMP/WHIP Input) + +ورودی استریم خارجی ایجاد می‌کند. + +#### Endpoint +```http +POST /api/ingress/create +``` + +#### Request (Protobuf) +```protobuf +message CreateIngressReq { + IngressInput inputType = 1; // RTMP_INPUT | WHIP_INPUT + string participantName = 2; + string roomId = 3; +} + +enum IngressInput { + RTMP_INPUT = 0; + WHIP_INPUT = 1; +} +``` + +#### Response (Protobuf) +```protobuf +message CreateIngressRes { + bool status = 1; + string msg = 2; + string ingressId = 3; + string url = 4; + string streamKey = 5; +} +``` + +#### Usage Example +پس از دریافت `url` و `streamKey`: +```bash +# استریم با FFmpeg +ffmpeg -re -i input.mp4 \ + -c:v libx264 -c:a aac \ + -f flv "rtmp://url/stream_key" +``` + +--- + +### 🗣️ Speech Services (Azure) + +#### Enable/Disable Speech Service + +**Endpoint:** +```http +POST /api/speechServices/serviceStatus +``` + +**Request (Protobuf):** +```protobuf +message SpeechToTextTranslationReq { + bool enabled = 1; +} +``` + +--- + +#### Get Azure Token + +**Endpoint:** +```http +POST /api/speechServices/azureToken +``` + +**Request (Protobuf):** +```protobuf +message GenerateAzureTokenReq { + string userSid = 1; +} +``` + +--- + +### 📁 File Upload & Whiteboard + +#### Upload File (Resumable) + +برای آپلود فایل‌های بزرگ به صورت chunk به chunk. + +**Endpoint:** +```http +POST /api/fileUpload?resumable=true&roomSid=xxx&roomId=xxx&userId=xxx +``` + +**Headers:** +```http +Authorization: +Content-Type: multipart/form-data +``` + +**Response:** `part_uploaded` or error + +--- + +#### Merge Uploaded Chunks + +**Endpoint:** +```http +POST /api/uploadedFileMerge +``` + +**Request (JSON):** +```json +{ + "roomSid": "RM_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "resumableIdentifier": "unique-file-id", + "resumableFilename": "document.pdf", + "resumableTotalChunks": 10 +} +``` + +**Response (JSON):** +```json +{ + "status": true, + "msg": "file merged successfully", + "filePath": "/uploads/document.pdf", + "fileName": "document.pdf", + "fileExtension": "pdf" +} +``` + +--- + +#### Convert Whiteboard File + +فایل‌های PDF/Office را به تصاویر برای وایت‌برد تبدیل می‌کند. + +> **پیش‌نیاز**: `libreoffice` و `mupdf-tools` (mutool) باید روی سرور نصب باشند. + +**Endpoint:** +```http +POST /api/convertWhiteboardFile +``` + +**Request (JSON):** +```json +{ + "roomSid": "RM_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "filePath": "/uploads/document.pdf", + "userId": "teacher-123" +} +``` + +**Response (JSON):** +```json +{ + "status": true, + "msg": "file converted successfully", + "fileName": "document", + "fileId": "file_xxxxxxxxxxxx", + "filePath": "/whiteboard/document/", + "totalPages": 15 +} +``` + +--- + +## 🔧 Other Services + +### 🔔 Webhook + +برای دریافت رویدادهای LiveKit. + +**Endpoint:** +```http +POST /webhook +``` + +**Headers:** +```http +Authorization: +``` + +**Webhook Events:** +- `room_started` - شروع روم +- `room_finished` - پایان روم +- `participant_joined` - ورود کاربر +- `participant_left` - خروج کاربر +- `track_published` - انتشار track +- `track_unpublished` - حذف track +- `recording_started` - شروع ضبط +- `recording_finished` - پایان ضبط +- و بیشتر... + +--- + +### ❤️ Health Check + +وضعیت سلامت سرور را بررسی می‌کند. + +**Endpoint:** +```http +GET /healthCheck +``` + +**Response:** +``` +Healthy +``` + +سرویس‌های بررسی شده: +- ✅ Database (MySQL/MariaDB) +- ✅ Redis +- ✅ NATS + +--- + +### 📥 Download Services + +#### Download Uploaded File +```http +GET /download/uploadedFile/:sid/* +``` + +#### Download Recording +```http +GET /download/recording/:token +``` + +#### Download Analytics +```http +GET /download/analytics/:token +``` + +--- + +## 🔄 Compatibility APIs + +### 🟦 BigBlueButton (BBB) Compatibility + +Plugnmeet از API های BigBlueButton پشتیبانی می‌کند. + +**Base Path:** +``` +/:apiKey/bigbluebutton/api +``` + +**Available Endpoints:** +- `GET/POST /create` - ایجاد جلسه +- `GET/POST /join` - ورود به جلسه +- `GET/POST /isMeetingRunning` - بررسی فعال بودن +- `GET/POST /getMeetingInfo` - اطلاعات جلسه +- `GET/POST /getMeetings` - لیست جلسات +- `GET/POST /end` - پایان جلسه +- `GET/POST /getRecordings` - لیست ضبط‌ها +- `GET/POST /deleteRecordings` - حذف ضبط +- `GET/POST /publishRecordings` - انتشار ضبط +- `GET/POST /updateRecordings` - به‌روزرسانی ضبط + +**Authentication:** نیازمند `checksum` مطابق استاندارد BBB + +#### Example (BBB Join) +```bash +API_KEY="your-api-key" +SECRET="your-secret" +MEETING_ID="test-meeting" +USER_NAME="Ali" + +# ساخت query string +QUERY="meetingID=${MEETING_ID}&fullName=${USER_NAME}" + +# محاسبه checksum +CHECKSUM=$(echo -n "join${QUERY}${SECRET}" | sha1sum | awk '{print $1}') + +# URL نهایی +URL="https://your-domain.com/${API_KEY}/bigbluebutton/api/join?${QUERY}&checksum=${CHECKSUM}" + +echo "Join URL: $URL" +``` + +--- + +### 🎓 LTI (Learning Tools Interoperability) + +برای یکپارچگی با سیستم‌های LMS. + +**Base Path:** +``` +/lti/v1 +``` + +#### LTI Landing +```http +POST /lti/v1 +``` + +#### LTI API Endpoints + +نیازمند هدر `Authorization` خاص LTI: + +- `POST /lti/v1/api/room/join` - ورود به روم +- `POST /lti/v1/api/room/isActive` - بررسی فعال بودن +- `POST /lti/v1/api/room/end` - پایان روم +- `POST /lti/v1/api/recording/fetch` - لیست ضبط‌ها +- `POST /lti/v1/api/recording/download` - دانلود ضبط +- `POST /lti/v1/api/recording/delete` - حذف ضبط + +--- + +## 🛠️ SDKs & Tools + +### Official SDKs + +#### PHP SDK +```bash +composer require mynaparrot/plugnmeet-sdk-php +``` + +```php +roomId = 'test-room'; +$params->metadata->roomTitle = 'کلاس آزمایشی'; + +$result = $plugnmeet->room->create($params); +``` + +--- + +#### JavaScript/Node.js SDK +```bash +npm install plugnmeet-sdk-js +``` + +```javascript +const { PlugNmeet } = require('plugnmeet-sdk-js'); + +const plugnmeet = new PlugNmeet({ + host: 'https://your-domain.com', + apiKey: 'your-api-key', + apiSecret: 'your-secret' +}); + +// ایجاد روم +const result = await plugnmeet.room.create({ + roomId: 'test-room', + metadata: { + roomTitle: 'کلاس آزمایشی' + } +}); + +// تولید توکن ورود +const token = await plugnmeet.room.getJoinToken({ + roomId: 'test-room', + userInfo: { + userId: 'user-123', + name: 'علی احمدی', + isAdmin: false + } +}); +``` + +--- + +### Docker Deployment + +```bash +docker run -d \ + --name plugnmeet-server \ + -p 8080:8080 \ + -v $PWD/config.yaml:/config.yaml \ + mynaparrot/plugnmeet-server \ + --config /config.yaml +``` + +--- + +## 📚 Additional Resources + +### Documentation +- 🌐 **Official Website**: https://www.plugnmeet.org +- 📖 **Full Documentation**: https://www.plugnmeet.org/docs +- 🔧 **Installation Guide**: https://www.plugnmeet.org/docs/installation +- 👨‍💻 **Developer Guide**: https://www.plugnmeet.org/docs/developer-guide + +### Community & Support +- 💬 **Discord**: https://discord.gg/2X2ZaCHu4C +- 🐛 **GitHub Issues**: https://github.com/mynaparrot/plugNmeet-server/issues +- 📧 **Email Support**: support@plugnmeet.com + +### Source Code +- 🖥️ **Server**: https://github.com/mynaparrot/plugNmeet-server +- 🎨 **Client**: https://github.com/mynaparrot/plugNmeet-client +- 🎬 **Recorder**: https://github.com/mynaparrot/plugNmeet-recorder + +--- + +## 📝 Notes & Best Practices + +### Performance Tips +1. ✅ از Redis برای caching استفاده کنید +2. ✅ برای مقیاس‌پذیری از Load Balancer استفاده کنید +3. ✅ ضبط‌ها را در storage خارجی (S3, MinIO) ذخیره کنید +4. ✅ از CDN برای سرویس‌دهی فایل‌های استاتیک استفاده کنید + +### Security Best Practices +1. 🔒 HTTPS را فعال کنید (الزامی) +2. 🔒 `apiKey` و `secret` را محرمانه نگه دارید +3. 🔒 از CORS Policy مناسب استفاده کنید +4. 🔒 توکن‌ها را با expiration time محدود تولید کنید +5. 🔒 Webhook signature را همیشه تایید کنید + +### Rate Limiting +- `/auth` endpoints: 100 req/min per IP +- `/api` endpoints: 1000 req/min per token +- File uploads: 10 MB/s per user + +--- + +## 🎯 Quick Start Checklist + +- [ ] LiveKit Server راه‌اندازی شده +- [ ] Redis نصب و پیکربندی شده +- [ ] MySQL/MariaDB آماده است +- [ ] فایل `config.yaml` تنظیم شده +- [ ] Plugnmeet Server در حال اجراست +- [ ] Client UI در دسترس است +- [ ] Test meeting ایجاد و تست شده +- [ ] Webhook تنظیم شده (اختیاری) +- [ ] Recording تست شده (اختیاری) + +--- + +## 🎉 نسخه و تاریخچه تغییرات + +**نسخه فعلی مستند**: 2.0.0 +**آخرین به‌روزرسانی**: نوامبر 2024 + +برای مشاهده تاریخچه کامل تغییرات به فایل [CHANGELOG.md](./CHANGELOG.md) مراجعه کنید. + +--- + +
+ +**ساخته شده با ❤️ توسط [MynaParrot](https://www.mynaparrot.com)** + +[Website](https://www.plugnmeet.org) • [GitHub](https://github.com/mynaparrot/plugNmeet-server) • [Discord](https://discord.gg/2X2ZaCHu4C) + +
diff --git a/docs/plugnmeet_webhook.md b/docs/plugnmeet_webhook.md new file mode 100644 index 0000000..ee0d253 --- /dev/null +++ b/docs/plugnmeet_webhook.md @@ -0,0 +1,360 @@ +# PlugNMeet Webhook Integration + +## Overview + +This document describes the webhook integration between PlugNMeet and the Django backend to handle live session events. + +## Webhook Endpoint + +**URL:** `https://habibmeet.nwhco.ir/api/course/plugnmeet/webhook/` + +**Method:** `POST` + +**Content-Type:** `application/webhook+json` + +## Authentication + +The webhook endpoint is secured using SHA256 HMAC signature verification. + +### Headers + +- `Hash-Token`: SHA256 HMAC signature of the request body using `PLUGNMEET_API_SECRET` +- `Content-Type`: `application/webhook+json` +- `Authorization`: JWT token (optional, for additional verification) + +### Signature Verification + +```python +import hmac +import hashlib + +# Calculate signature +signature = hmac.new( + PLUGNMEET_API_SECRET.encode('utf-8'), + request_body, + hashlib.sha256 +).hexdigest() + +# Compare with Hash-Token header +is_valid = hmac.compare_digest(hash_token_header, signature) +``` + +## Supported Events + +### 1. room_finished + +Triggered when a live session room is closed. + +**Action:** Closes the `CourseLiveSession` and marks all active `LiveSessionUser` entries as offline. + +**Payload:** +```json +{ + "event": "room_finished", + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": 1697500800, + "room": { + "sid": "room-123456", + "identity": "algebra-1402", + "name": "کلاس جبر", + "maxParticipants": 100, + "creationTime": 1697497200, + "metadata": "{}", + "numParticipants": 0, + "duration": 3600 + } +} +``` + +**Response:** +```json +{ + "status": "ok", + "message": "Room finished", + "session_id": 123, + "users_disconnected": 5 +} +``` + +### 2. participant_joined + +Triggered when a user joins the live session. + +**Action:** Creates a new `LiveSessionUser` entry or reactivates an existing offline entry. + +**Payload:** +```json +{ + "event": "participant_joined", + "id": "660e8400-e29b-41d4-a716-446655440001", + "createdAt": 1697497300, + "room": { + "sid": "room-123456", + "identity": "algebra-1402", + "name": "کلاس جبر" + }, + "participant": { + "sid": "participant-user-27", + "identity": "27", + "state": "ACTIVE", + "name": "دانشجو نمونه", + "metadata": "{\"is_admin\": false}", + "permission": { + "canPublish": true, + "canPublishData": true, + "canSubscribe": true + }, + "tracks": [], + "joinedAt": 1697497300 + } +} +``` + +**Response:** +```json +{ + "status": "ok", + "message": "Participant joined", + "session_user_id": 456, + "created": true +} +``` + +### 3. participant_left + +Triggered when a user leaves the live session. + +**Action:** Marks the user's `LiveSessionUser` entry as offline and sets `exited_at` timestamp. + +**Payload:** +```json +{ + "event": "participant_left", + "id": "770e8400-e29b-41d4-a716-446655440002", + "createdAt": 1697499000, + "room": { + "sid": "room-123456", + "identity": "algebra-1402", + "name": "کلاس جبر" + }, + "participant": { + "sid": "participant-user-27", + "identity": "27", + "state": "DISCONNECTED", + "name": "دانشجو نمونه", + "metadata": "{\"is_admin\": false}", + "permission": { + "canPublish": true, + "canPublishData": true, + "canSubscribe": true + }, + "tracks": [], + "joinedAt": 1697497300, + "duration": 1800 + } +} +``` + +**Response:** +```json +{ + "status": "ok", + "message": "Participant left", + "updated": true +} +``` + +### 4. end_recording + +Triggered when a recording finishes. + +**Action:** +1. Fetches recording info from PlugNMeet API +2. Gets download token +3. Downloads the recording file +4. Saves to `LiveSessionRecording` model +5. Generates thumbnail for video recordings (requires `ffmpeg`) + +**Payload:** +```json +{ + "event": "end_recording", + "id": "880e8400-e29b-41d4-a716-446655440003", + "createdAt": 1697500800, + "room": { + "sid": "room-123456", + "identity": "algebra-1402", + "name": "کلاس جبر" + }, + "recording_info": { + "recordingId": "rec-123456", + "roomId": "algebra-1402", + "recordingType": "COMPOSITE", + "fileName": "algebra-1402-20231016.mp4", + "duration": 3600, + "status": "FINISHED" + } +} +``` + +**Response (Success):** +```json +{ + "status": "ok", + "message": "Recording downloaded and saved successfully", + "recording_id": 123, + "file_name": "algebra-1402-20231016.mp4", + "file_size": 524288000, + "thumbnail_generated": true +} +``` + +**Response (Error):** +```json +{ + "status": "error", + "message": "Failed to get recording info", + "error": "Recording not found" +} +``` + +**Requirements:** +- `ffmpeg` must be installed for thumbnail generation +- Sufficient disk space for recording files +- Write permissions on media directories + +## Configuration + +Add these settings to your Django settings file: + +```python +# PlugNMeet Integration +PLUGNMEET_SERVER_URL = "https://plugnmeet.example.com" +PLUGNMEET_API_KEY = "your-api-key" +PLUGNMEET_API_SECRET = "your-api-secret" +PLUGNMEET_TIMEOUT = 10.0 +``` + +## PlugNMeet Configuration + +Configure the webhook URL in your PlugNMeet server settings: + +```yaml +webhooks: + - url: "https://habibmeet.nwhco.ir/api/course/plugnmeet/webhook/" + events: + - room_finished + - participant_joined + - participant_left + - end_recording +``` + +## Error Handling + +The webhook endpoint returns appropriate HTTP status codes: + +- `200 OK`: Event processed successfully +- `400 Bad Request`: Invalid payload or missing required fields +- `403 Forbidden`: Invalid webhook signature +- `500 Internal Server Error`: Server error during processing + +All errors are logged with detailed information for debugging. + +## Testing + +To test the webhook locally, you can use curl: + +```bash +# Calculate signature +SECRET="your-api-secret" +PAYLOAD='{"event":"room_finished","room":{"identity":"test-room"}}' +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) + +# Send webhook request +curl -X POST https://habibmeet.nwhco.ir/api/course/plugnmeet/webhook/ \ + -H "Content-Type: application/webhook+json" \ + -H "Hash-Token: $SIGNATURE" \ + -d "$PAYLOAD" +``` + +## Migration from Polling + +Previously, the system used a polling approach where it would check if a room was still active using the `is_room_active` API call. This has been deprecated in favor of the webhook approach: + +**Old (Deprecated):** +```python +# This code has been commented out +def _sync_room_status_with_plugnmeet(self, course: Course): + client = PlugNMeetClient() + response = client.is_room_active(active_session.room_id) + if not response.get('isActive', False): + self._close_live_session(active_session) +``` + +**New (Webhook-based):** +```python +# Room status is automatically updated via webhooks +# No polling required +``` + +## Benefits + +1. **Real-time updates**: No polling delay, events are processed immediately +2. **Reduced server load**: No need for periodic API calls to check room status +3. **Accurate tracking**: Precise participant join/leave timestamps +4. **Scalability**: Webhook approach scales better than polling +5. **Lower latency**: Users see status updates immediately +6. **Automatic recording management**: Recordings are automatically downloaded and saved when ready + +## Recording Management + +The webhook automatically handles recording downloads when the `end_recording` event is received: + +### Process Flow + +1. **Webhook receives end_recording event** +2. **Fetch recording info** from PlugNMeet API (`/auth/recording/recordingInfo`) +3. **Get download token** (`/auth/recording/getDownloadToken`) +4. **Download file** to temporary location +5. **Determine recording type** (video/voice) based on file extension +6. **Create database record** (`LiveSessionRecording`) +7. **Generate thumbnail** (for video files using ffmpeg) +8. **Clean up** temporary files + +### File Storage + +- **Location**: Configured by `MEDIA_ROOT` in Django settings +- **Upload path**: `recorded_sessions/` (from model definition) +- **Thumbnails**: `recording_thumbnails/` (for video recordings) + +### Thumbnail Generation + +For video recordings, a thumbnail is automatically generated using `ffmpeg`: +- Extracts frame at 1 second +- Scaled to width 640px (maintains aspect ratio) +- High quality JPEG (quality level 2) +- Saved to `recording.thumbnail` field + +**Note**: `ffmpeg` must be installed on the server for thumbnail generation to work. + +### Error Handling + +The recording download process includes comprehensive error handling: +- Missing recording: Returns appropriate error message +- Download failures: Logs error and returns failure status +- Thumbnail generation: Non-critical, failures are logged but don't stop the process +- Cleanup: Temporary files are always cleaned up, even on errors + +## Logging + +All webhook events are logged with detailed information: + +``` +[PlugNMeet Webhook] Received webhook request +[PlugNMeet Webhook] Processing event=room_finished +[PlugNMeet Webhook] Session closed - session_id=123 room_id=algebra-1402 +[PlugNMeet Webhook] User sessions closed - session_id=123 count=5 +[PlugNMeet Webhook] Event processed successfully - event=room_finished +``` + +Check Django logs for webhook activity and debugging information. diff --git a/dynamic_preferences/admin.py b/dynamic_preferences/admin.py index 496ae81..21dd326 100644 --- a/dynamic_preferences/admin.py +++ b/dynamic_preferences/admin.py @@ -8,6 +8,11 @@ from .models import GlobalPreferenceModel from .forms import GlobalSinglePreferenceForm, SinglePerInstancePreferenceForm from django.utils.translation import gettext_lazy as _ +<<<<<<< HEAD +======= +from unfold.admin import ModelAdmin, TabularInline +from utils.admin import project_admin_site +>>>>>>> develop class SectionFilter(admin.AllValuesFieldListFilter): def __init__(self, field, request, params, model, model_admin, field_path): @@ -40,7 +45,12 @@ class SectionFilter(admin.AllValuesFieldListFilter): yield choice +<<<<<<< HEAD class DynamicPreferenceAdmin(AjaxDatatable): +======= +# Change DynamicPreferenceAdmin to inherit from unfold's ModelAdmin +class DynamicPreferenceAdmin(ModelAdmin): +>>>>>>> develop list_display = ( "verbose_name", "help_text", @@ -48,7 +58,15 @@ class DynamicPreferenceAdmin(AjaxDatatable): fields = ("raw_value", "default_value",) readonly_fields = ("default_value",) change_form_template = "dynamic_preferences/dyna_change_form.html" - +<<<<<<< HEAD + +======= + + # Unfold specific settings + search_fields = ["name", "section"] + list_filter = ["section"] + +>>>>>>> develop @admin.display(description=_('Verbose name')) def verbose_name(self, obj): return obj.verbose_name @@ -94,6 +112,24 @@ class DynamicPreferenceAdmin(AjaxDatatable): class GlobalPreferenceAdmin(DynamicPreferenceAdmin): form = GlobalSinglePreferenceForm changelist_form = GlobalSinglePreferenceForm +<<<<<<< HEAD +======= + + # Unfold specific customizations + list_display_links = ["verbose_name"] + + # You can add unfold specific features like: + show_facets = True # Enable faceted filtering + + # Optional: Add custom actions + actions = ["reset_to_default"] + + def reset_to_default(self, request, queryset): + for pref in queryset: + manager = pref.registry.manager() + manager.update_db_pref(pref.section, pref.name, pref.preference.default) + reset_to_default.short_description = _("Reset selected preferences to default values") +>>>>>>> develop def get_queryset(self, *args, **kwargs): # Instanciate default prefs @@ -102,7 +138,14 @@ class GlobalPreferenceAdmin(DynamicPreferenceAdmin): return super(GlobalPreferenceAdmin, self).get_queryset(*args, **kwargs) +<<<<<<< HEAD admin.site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) +======= + + + +project_admin_site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) +>>>>>>> develop class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): @@ -112,3 +155,7 @@ class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): form = SinglePerInstancePreferenceForm changelist_form = SinglePerInstancePreferenceForm list_select_related = True +<<<<<<< HEAD +======= + +>>>>>>> develop diff --git a/dynamic_preferences/dynamic_preferences_registry.py b/dynamic_preferences/dynamic_preferences_registry.py index edc1dad..11617fc 100644 --- a/dynamic_preferences/dynamic_preferences_registry.py +++ b/dynamic_preferences/dynamic_preferences_registry.py @@ -10,11 +10,15 @@ from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.types import BasePreferenceType, BaseSerializer, LongStringPreference, StringPreference, \ FilePreference from utils.json_editor_field import JsonEditorWidget +from unfold.contrib.forms.widgets import WysiwygWidget, ArrayWidget +from unfold.widgets import UnfoldAdminTextareaWidget class EditorPreferences(LongStringPreference): - widget = TinyWidget(attrs={'class': 'editor-field'}) + widget = WysiwygWidget(attrs={'class': 'editor-field'}) +class EditorTextPreferences(LongStringPreference): + widget = UnfoldAdminTextareaWidget(attrs={'class': 'editor-field', 'rows': 20}) @global_preferences_registry.register class AboutUsConfig(EditorPreferences): @@ -180,3 +184,45 @@ class SupportConfig(JsonFieldCard): verbose_name = 'Card Detail' default = {} + + +@global_preferences_registry.register +class CalendarAdjustmentConfig(EditorTextPreferences): + section = Section('calendar', verbose_name='CalendarAdjustmentConfig') + name = 'Adjustment' + required = False + verbose_name = 'Calendar Adjustment Config' + default = '' + + + +about_us_dobodi = { + "type": "object", + "format": "table", + "title": "", + "required_by_default": 1, + "required": ['arabic_text', 'translated_text', 'title', 'content'], + "properties": { + "arabic_text": {"type": "string", "title": "text arabic"}, + "translated_text": {"type": "string", "title": "translated text"}, + "title": {"type": "string", "title": "title"}, + "content": {"type": "string", "format": "textarea", "title": "Content", "rows": 8}, + } +} + + + +class JsonFieldAbout(BasePreferenceType): + field_class = forms.JSONField + serializer = JsonSerializer + widget = JsonEditorWidget(attrs={'schema': about_us_dobodi}) + +@global_preferences_registry.register +class SupportConfig(JsonFieldAbout): + section = Section('about_us_dobodi', verbose_name='about Us Detail') + name = 'about_us_dobodi' + required = False + verbose_name = 'About Us Dobodi' + default = {} + + diff --git a/dynamic_preferences/locale/fa/LC_MESSAGES/django.po b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po index be7d11e..58e4376 100644 --- a/dynamic_preferences/locale/fa/LC_MESSAGES/django.po +++ b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po @@ -8,7 +8,11 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" +<<<<<<< HEAD "POT-Creation-Date: 2023-02-16 15:12+0330\n" +======= +"POT-Creation-Date: 2025-12-23 15:18+0330\n" +>>>>>>> develop "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +22,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +<<<<<<< HEAD #: admin.py:69 msgid "Default Value" msgstr "مقدار پیشفرض" @@ -69,3 +74,75 @@ msgstr "" #: users/models.py:15 msgid "user preferences" msgstr "" +======= +#: .\dynamic_preferences\admin.py:59 +#, fuzzy +#| msgid "Verbose Name" +msgid "Verbose name" +msgstr "نام" + +#: .\dynamic_preferences\admin.py:63 +#, fuzzy +#| msgid "Help Text" +msgid "Help text" +msgstr "متن راهنما" + +#: .\dynamic_preferences\admin.py:84 +msgid "Default Value" +msgstr "مقدار پیشفرض" + +#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 +msgid "Section Name" +msgstr "عنوان بخش" + +#: .\dynamic_preferences\admin.py:118 +msgid "Reset selected preferences to default values" +msgstr "" + +#: .\dynamic_preferences\apps.py:10 +msgid "Settings" +msgstr "" + +#: .\dynamic_preferences\models.py:34 +msgid "Name" +msgstr "نام" + +#: .\dynamic_preferences\models.py:37 +msgid "Raw Value" +msgstr "مقدار" + +#: .\dynamic_preferences\models.py:51 +msgid "Verbose Name" +msgstr "نام" + +#: .\dynamic_preferences\models.py:57 +msgid "Help Text" +msgstr "متن راهنما" + +#: .\dynamic_preferences\models.py:94 +msgid "Global preference" +msgstr "تنطیمات عمومی" + +#: .\dynamic_preferences\models.py:95 +msgid "Global preferences" +msgstr "تنطیمات عمومی" + +#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "ثبت" + +#: .\dynamic_preferences\users\apps.py:11 +msgid "Preferences - Users" +msgstr "" + +#: .\dynamic_preferences\users\models.py:14 +msgid "user preference" +msgstr "" + +#: .\dynamic_preferences\users\models.py:15 +msgid "user preferences" +msgstr "" + +#~ msgid "Dynamic Preferences" +#~ msgstr "تنظیمات" +>>>>>>> develop diff --git a/dynamic_preferences/locale/ru/LC_MESSAGES/django.po b/dynamic_preferences/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1fa761 --- /dev/null +++ b/dynamic_preferences/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,80 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:07+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" +#: dynamic_preferences/admin.py:52 +msgid "Verbose name" +msgstr "" + +#: dynamic_preferences/admin.py:56 +msgid "Help text" +msgstr "" + +#: dynamic_preferences/admin.py:77 +msgid "Default Value" +msgstr "" + +#: dynamic_preferences/admin.py:86 dynamic_preferences/models.py:30 +msgid "Section Name" +msgstr "" + +#: dynamic_preferences/apps.py:10 +msgid "Settings" +msgstr "" + +#: dynamic_preferences/models.py:34 +msgid "Name" +msgstr "" + +#: dynamic_preferences/models.py:37 +msgid "Raw Value" +msgstr "" + +#: dynamic_preferences/models.py:51 +msgid "Verbose Name" +msgstr "" + +#: dynamic_preferences/models.py:57 +msgid "Help Text" +msgstr "" + +#: dynamic_preferences/models.py:94 +msgid "Global preference" +msgstr "" + +#: dynamic_preferences/models.py:95 +msgid "Global preferences" +msgstr "" + +#: dynamic_preferences/templates/dynamic_preferences/form.html:11 +msgid "Submit" +msgstr "" + +#: dynamic_preferences/users/apps.py:11 +msgid "Preferences - Users" +msgstr "" + +#: dynamic_preferences/users/models.py:14 +msgid "user preference" +msgstr "" + +#: dynamic_preferences/users/models.py:15 +msgid "user preferences" +msgstr "" diff --git a/dynamic_preferences/migrations/0001_initial.py b/dynamic_preferences/migrations/0001_initial.py index 2ca4437..77ddefe 100644 --- a/dynamic_preferences/migrations/0001_initial.py +++ b/dynamic_preferences/migrations/0001_initial.py @@ -1,50 +1,28 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +# Generated by Django 5.1.8 on 2025-04-03 00:05 -from django.db import models, migrations -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="GlobalPreferenceModel", + name='GlobalPreferenceModel', fields=[ - ( - "id", - models.AutoField( - primary_key=True, - serialize=False, - verbose_name="ID", - auto_created=True, - ), - ), - ( - "section", - models.CharField( - blank=True, - default=None, - null=True, - max_length=150, - db_index=True, - ), - ), - ("name", models.CharField(max_length=150, db_index=True)), - ("raw_value", models.TextField(blank=True, null=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('section', models.CharField(blank=True, db_index=True, default=None, max_length=150, null=True, verbose_name='Section Name')), + ('name', models.CharField(db_index=True, max_length=150, verbose_name='Name')), + ('raw_value', models.TextField(blank=True, null=True, verbose_name='Raw Value')), ], options={ - "verbose_name_plural": "global preferences", - "verbose_name": "global preference", + 'verbose_name': 'Global preference', + 'verbose_name_plural': 'Global preferences', + 'unique_together': {('section', 'name')}, }, - bases=(models.Model,), - ), - migrations.AlterUniqueTogether( - name="globalpreferencemodel", - unique_together=set([("section", "name")]), ), ] diff --git a/dynamic_preferences/serializers.py b/dynamic_preferences/serializers.py index bf57339..5132b56 100644 --- a/dynamic_preferences/serializers.py +++ b/dynamic_preferences/serializers.py @@ -31,6 +31,13 @@ from rest_framework import serializers class AboutUsSerializer(serializers.Serializer): content = serializers.CharField(allow_blank=True) +class AboutUsDobodiSerializer(serializers.Serializer): + arabic_text = serializers.CharField(allow_blank=True) + translated_text = serializers.CharField(allow_blank=True) + title = serializers.CharField(allow_blank=True) + content = serializers.CharField(allow_blank=True) + + class FAQItemSerializer(serializers.Serializer): question = serializers.CharField() answer = serializers.CharField() diff --git a/dynamic_preferences/urls.py b/dynamic_preferences/urls.py index dab3388..d95162d 100644 --- a/dynamic_preferences/urls.py +++ b/dynamic_preferences/urls.py @@ -14,7 +14,8 @@ from .views import ( FAQCourseAPIView, FAQGeneralAPIView, SupportAPIView, - CardAPIView + CardAPIView, + AboutUsDobodiAPIView ) app_name = "dynamic_preferences" @@ -29,6 +30,7 @@ urlpatterns = [ path('faq-general/', FAQGeneralAPIView.as_view(), name='faq-general-api'), path('support/', SupportAPIView.as_view(), name='support-api'), path('card/', CardAPIView.as_view(), name='card-api'), + path('about-us-dobodi/', AboutUsDobodiAPIView.as_view(), name='about-us-dobodi-api'), re_path( diff --git a/dynamic_preferences/views.py b/dynamic_preferences/views.py index f12572b..05bc81b 100644 --- a/dynamic_preferences/views.py +++ b/dynamic_preferences/views.py @@ -8,7 +8,8 @@ from .serializers import ( AboutUsSerializer, FAQItemSerializer, SupportSerializer, - CardSerializer + CardSerializer, + AboutUsDobodiSerializer ) class AboutUsAPIView(GenericAPIView): @@ -60,6 +61,16 @@ class CardAPIView(GenericAPIView): serializer = self.get_serializer(data=card) serializer.is_valid(raise_exception=True) return Response(serializer.data) + +class AboutUsDobodiAPIView(GenericAPIView): + serializer_class = AboutUsDobodiSerializer + + def get(self, request, *args, **kwargs): + preferences = global_preferences_registry.manager() + about_us_dobodi = preferences.get('about_us_dobodi__about_us_dobodi', {}) + serializer = self.get_serializer(data=about_us_dobodi) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) """Todo : remove these views and use only context processors""" diff --git a/entrypoint.sh b/entrypoint.sh index 6317c6c..a37faf7 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,5 +2,7 @@ sleep 20 python manage.py migrate +python manage.py seed_images +python manage.py collectstatic --noinput exec "$@" diff --git a/fix_db.py b/fix_db.py new file mode 100644 index 0000000..137f794 --- /dev/null +++ b/fix_db.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +import psycopg2 + +# Connect directly to the database +try: + conn = psycopg2.connect( + dbname="imam_javad_db", + user="postgres", + password="123456789", + host="localhost", + port="5432" + ) + cursor = conn.cursor() + print("Connected to database successfully") +except Exception as e: + print(f"Failed to connect to database: {e}") + exit(1) + +# Add missing transmitter fields +fields_to_add = [ + ('kunya', 'VARCHAR(255) NULL'), + ('known_as', 'VARCHAR(255) NULL'), + ('nickname', 'VARCHAR(255) NULL'), + ('origin', 'VARCHAR(255) NULL'), + ('lived_in', 'VARCHAR(255) NULL'), + ('died_in', 'VARCHAR(255) NULL'), + ('age_at_death', 'INTEGER NULL'), + ('reliability', "VARCHAR(20) DEFAULT 'unknown'"), + ('madhhab', "VARCHAR(20) DEFAULT 'unknown'"), + ('in_sahih_bukhari', 'BOOLEAN DEFAULT FALSE'), + ('in_sahih_muslim', 'BOOLEAN DEFAULT FALSE'), + ('created_at', 'TIMESTAMP WITH TIME ZONE DEFAULT NOW()'), + ('updated_at', 'TIMESTAMP WITH TIME ZONE DEFAULT NOW()'), +] + +print("Adding missing transmitter fields...") +for field_name, field_type in fields_to_add: + try: + cursor.execute(f'ALTER TABLE hadis_transmitters ADD COLUMN IF NOT EXISTS {field_name} {field_type};') + print(f'✓ Added column: {field_name}') + except Exception as e: + print(f'✗ Error adding {field_name}: {e}') + +conn.commit() +print('All missing transmitter fields added successfully!') + +# Test if the fields exist +cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'hadis_transmitters' AND column_name = 'kunya';") +result = cursor.fetchone() +if result: + print('✓ kunya column exists in database') +else: + print('✗ kunya column not found') + +conn.close() diff --git a/fix_transmitter_opinion.py b/fix_transmitter_opinion.py new file mode 100644 index 0000000..41237fc --- /dev/null +++ b/fix_transmitter_opinion.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +import psycopg2 + +# Connect directly to the database +try: + conn = psycopg2.connect( + dbname="imam_javad_db", + user="postgres", + password="123456789", + host="localhost", + port="5432" + ) + cursor = conn.cursor() + print("Connected to database successfully") +except Exception as e: + print(f"Failed to connect to database: {e}") + exit(1) + +# Create the missing TransmitterOpinion table +sql = """ +CREATE TABLE IF NOT EXISTS hadis_transmitteropinion ( + id BIGSERIAL PRIMARY KEY, + scholar_name VARCHAR(255) NOT NULL, + opinion_text TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'confirmed', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + transmitter_id BIGINT REFERENCES hadis_transmitters(id) ON DELETE CASCADE +); +""" + +try: + cursor.execute(sql) + print("✓ Created hadis_transmitteropinion table") +except Exception as e: + print(f"✗ Error creating table: {e}") + +conn.commit() +conn.close() + +print("TransmitterOpinion table creation completed!") diff --git a/locale/fa/LC_MESSAGES/django.po b/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 0000000..ef9a417 --- /dev/null +++ b/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,1328 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#: config/settings/base.py:485 config/settings/base.py:496 +#: config/settings/base.py:507 config/settings/base.py:518 +#: config/settings/base.py:530 +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:00+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: apps/account/admin/professor.py:59 apps/account/admin/student.py:46 +msgid "Personal info" +msgstr "اطلاعات شخصی" + +#: apps/account/admin/professor.py:60 apps/account/admin/student.py:47 +#: apps/account/admin/user.py:72 apps/account/admin/user.py:99 +#: apps/account/admin/user.py:288 apps/account/admin/user.py:291 +#: templates/admin/filer/folder/directory_table.html:129 +msgid "Permissions" +msgstr "مجوزها" + +#: apps/account/admin/professor.py:63 apps/account/admin/student.py:50 +#: apps/account/admin/user.py:105 +msgid "Important dates" +msgstr "تاریخ‌های مهم" + +#: apps/account/admin/user.py:64 apps/account/admin/user.py:166 +msgid "Location" +msgstr "موقعیت" + +#: apps/account/admin/user.py:68 +msgid "Password" +msgstr "رمز عبور" + +#: apps/account/admin/user.py:80 +msgid "Basic Information" +msgstr "اطلاعات پایه" + +#: apps/account/admin/user.py:87 +msgid "Country & City" +msgstr "کشور و شهر" + +#: apps/account/admin/user.py:93 +msgid "Device Information" +msgstr "اطلاعات دستگاه" + +#: apps/account/admin/user.py:122 apps/account/admin/user.py:147 +msgid "Date Joined" +msgstr "تاریخ عضویت" + +#: apps/account/admin/user.py:126 +msgid "Last Login" +msgstr "آخرین ورود" + +#: apps/account/admin/user.py:170 +msgid "password" +msgstr "رمز عبور" + +#: apps/account/admin/user.py:177 apps/course/admin/course.py:159 +msgid "Student" +msgstr "دانشجو" + +#: apps/account/admin/user.py:197 +msgid "Age" +msgstr "سن" + +#: apps/account/admin/user.py:217 apps/account/admin/user.py:349 +#: apps/course/admin/course.py:79 config/settings/base.py:447 +#: config/settings/base.py:542 config/settings/base.py:547 +msgid "Courses" +msgstr "دوره‌ها" + +#: apps/account/admin/user.py:298 apps/course/admin/course.py:45 +msgid "Course Categories" +msgstr "دسته‌بندی‌های دوره" + +#: apps/account/admin/user.py:317 apps/course/admin/course.py:64 +msgid "Edit" +msgstr "ویرایش" + +#: apps/account/admin/user.py:329 apps/course/admin/course.py:312 +msgid "Professor" +msgstr "استاد" + +#: apps/account/models/notification.py:10 apps/hadis/models/hadis.py:10 +#: apps/hadis/models/hadis.py:21 apps/hadis/models/hadis.py:49 +#: apps/podcast/models.py:6 apps/quiz/models/quiz.py:9 apps/video/models.py:7 +msgid "title" +msgstr "عنوان" + +#: apps/account/models/notification.py:11 +msgid "message" +msgstr "پیام" + +#: apps/account/models/notification.py:12 +msgid "user" +msgstr "کاربر" + +#: apps/account/models/notification.py:13 +msgid "is read" +msgstr "خوانده شده" + +#: apps/account/models/notification.py:18 +msgid "service" +msgstr "سرویس" + +#: apps/account/models/notification.py:20 apps/hadis/models/hadis.py:28 +#: apps/hadis/models/hadis.py:54 apps/hadis/models/hadis.py:66 +#: apps/hadis/models/transmitter.py:44 apps/library/models.py:70 +#: apps/library/models.py:114 apps/podcast/models.py:11 +#: apps/podcast/models.py:26 apps/podcast/models.py:78 apps/video/models.py:12 +#: apps/video/models.py:27 apps/video/models.py:85 +msgid "created at" +msgstr "ایجاد شده " + +#: apps/account/models/notification.py:21 apps/hadis/models/hadis.py:29 +#: apps/library/models.py:71 apps/library/models.py:115 +#: apps/podcast/models.py:12 apps/podcast/models.py:27 +#: apps/podcast/models.py:79 apps/video/models.py:13 apps/video/models.py:28 +#: apps/video/models.py:86 +msgid "updated at" +msgstr "به‌روزرسانی شده " + +#: apps/account/models/user.py:34 apps/transaction/models.py:43 +msgid "birthdate" +msgstr "تاریخ تولد" + +#: apps/account/models/user.py:41 +msgid "Phone Number" +msgstr "شماره تلفن" + +#: apps/account/models/user.py:46 apps/transaction/models.py:41 +msgid "Gender" +msgstr "جنسیت" + +#: apps/account/models/user.py:50 +msgid "City" +msgstr "شهر" + +#: apps/account/models/user.py:51 apps/account/models/user.py:121 +msgid "country" +msgstr "کشور" + +#: apps/account/models/user.py:53 +msgid "device id" +msgstr "شناسه دستگاه" + +#: apps/account/models/user.py:119 +msgid "lat" +msgstr "عرض جغرافیایی" + +#: apps/account/models/user.py:120 +msgid "lon" +msgstr "طول جغرافیایی" + +#: apps/account/models/user.py:122 +msgid "city" +msgstr "شهر" + +#: apps/account/templates/account/group_help_text.html:5 +msgid "Driver before template" +msgstr "راننده قبل از قالب" + +#: apps/account/templates/account/group_help_text.html:11 +msgid "Active drivers" +msgstr "رانندگان فعال" + +#: apps/account/templates/account/group_help_text.html:19 +msgid "Inactive drivers" +msgstr "رانندگان غیرفعال" + +#: apps/account/templates/account/group_help_text.html:27 +msgid "Total points" +msgstr "مجموع امتیازات" + +#: apps/account/templates/account/group_help_text.html:35 +msgid "Total races" +msgstr "مجموع مسابقات" + +#: apps/account/templates/account/user_list_section.html:8 +msgid "Total Actice Users" +msgstr "مجموع کاربران فعال" + +#: apps/account/templates/account/user_list_section.html:16 +msgid "Total Guest Users" +msgstr "مجموع کاربران مهمان" + +#: apps/account/templates/account/user_list_section.html:22 +msgid "Total Students" +msgstr "مجموع دانشجویان" + +#: apps/account/templates/account/user_list_section.html:28 +msgid "Total Professors" +msgstr "مجموع اساتید" + +#: apps/api/admin.py:27 +msgid "Dimensions" +msgstr "ابعاد" + +#: apps/api/admin.py:31 +msgid "Preview" +msgstr "پیش‌نمایش" + +#: apps/certificate/admin.py:23 apps/transaction/admin.py:37 +msgid "Timestamps" +msgstr "مهرهای زمانی" + +#: apps/certificate/admin.py:29 apps/course/admin/course.py:273 +#: apps/library/admin.py:20 apps/video/admin.py:78 +msgid "Status" +msgstr "وضعیت" + +#: apps/certificate/models.py:12 +msgid "pending" +msgstr " انتظار" + +#: apps/certificate/models.py:13 +msgid "approved" +msgstr "تأیید شده" + +#: apps/certificate/models.py:14 +msgid "canceled" +msgstr "لغو شده" + +#: apps/certificate/models.py:20 +msgid "certificate_file" +msgstr "فایل گواهینامه" + +#: apps/course/admin/course.py:97 +msgid "Course Weekly Schedule" +msgstr "برنامه هفتگی دوره" + +#: apps/course/admin/course.py:101 utils/schema.py:47 +msgid "Course Features" +msgstr "ویژگی‌های دوره" + +#: apps/course/admin/course.py:154 +msgid "Enrollment Details" +msgstr "جزئیات ثبت‌نام" + +#: apps/course/admin/course.py:179 apps/course/admin/course.py:293 +msgid "Course" +msgstr "دوره" + +#: apps/course/admin/course.py:191 +msgid "Participant" +msgstr "شرکت‌کننده" + +#: apps/course/admin/course.py:192 +msgid "Participants" +msgstr "شرکت‌کنندگان" + +#: apps/course/admin/course.py:222 +msgid "Select Student" +msgstr "انتخاب دانشجو" + +#: apps/course/admin/course.py:276 +msgid "Course Details" +msgstr "جزئیات دوره" + +#: apps/course/admin/course.py:280 +msgid "Media" +msgstr "رسانه" + +#: apps/course/admin/course.py:283 +msgid "Pricing" +msgstr "قیمت‌گذاری" + +#: apps/course/admin/course.py:286 +msgid "Timing & Features" +msgstr "زمان‌بندی و ویژگی‌ها" + +#: apps/course/admin/course.py:301 +msgid "No description" +msgstr "بدون توضیحات" + +#: apps/course/admin/course.py:316 apps/transaction/admin.py:49 +msgid "Price" +msgstr "قیمت" + +#: apps/course/admin/course.py:319 +msgid "Free" +msgstr "رایگان" + +#: apps/course/admin/course.py:340 +msgid "View Lessons" +msgstr "مشاهده دروس" + +#: apps/course/admin/course.py:351 apps/course/admin/course.py:386 +msgid "Course not found" +msgstr "دوره یافت نشد" + +#: apps/course/admin/course.py:376 +msgid "Add Student to Course" +msgstr "افزودن دانشجو به دوره" + +#: apps/course/admin/course.py:396 +#, python-brace-format +msgid "Student {student.fullname} is already enrolled in this course" +msgstr "دانشجو {student.fullname} قبلاً در این دوره ثبت‌نام کرده است" + +#: apps/course/admin/course.py:405 +#, python-brace-format +msgid "" +"Student {student.fullname} has been successfully added to {course.title}" +msgstr "دانشجو {student.fullname} با موفقیت به دوره {course.title} اضافه شد" + +#: apps/course/admin/course.py:418 +msgid "Change detail action for {}" +msgstr "تغییر عملیات جزئیات برای {}" + +#: apps/course/admin/lesson.py:59 apps/hadis/admin/hadis.py:148 +msgid "Content" +msgstr "محتوا" + +#: apps/course/admin/lesson.py:82 +msgid "Duration" +msgstr "مدت زمان" + +#: apps/course/models/course.py:69 +msgid "Thumbnail" +msgstr "تصویر بندانگشتی" + +#: apps/course/models/course.py:95 +msgid "Course Final Price" +msgstr "قیمت نهایی دوره" + +#: apps/course/models/course.py:96 +msgid "" +"This field is automatically calculated based on the discount percentage." +msgstr "این فیلد به صورت خودکار بر اساس درصد تخفیف محاسبه می‌شود." + +#: apps/course/models/course.py:99 +msgid "Timing" +msgstr "زمان‌بندی" + +#: apps/course/models/course.py:100 +msgid "Course features" +msgstr "ویژگی‌های دوره" + +#: apps/course/models/course.py:101 apps/course/models/lesson.py:35 +#: apps/course/models/lesson.py:98 apps/transaction/models.py:20 +msgid "Created at" +msgstr "ایجاد شده در" + +#: apps/course/models/course.py:102 apps/course/models/lesson.py:36 +msgid "Updated At" +msgstr "به‌روزرسانی شده در" + +#: apps/course/models/lesson.py:25 +msgid "Is Active" +msgstr "فعال است" + +#: apps/course/templates/course/add_student_form.html:25 +msgid "Submit form" +msgstr "ارسال فرم" + +#: apps/hadis/admin/category.py:38 apps/hadis/models/category.py:21 +msgid "Source Type" +msgstr "نوع منبع" + +#: apps/hadis/admin/category.py:69 +msgid "This item can not be modified" +msgstr "این مورد قابل تغییر نیست" + +#: apps/hadis/admin/category.py:198 +msgid "Category saved successfully. Tree will be reloaded." +msgstr "دسته‌بندی با موفقیت ذخیره شد. درخت مجدداً بارگذاری خواهد شد." + +#: apps/hadis/admin/hadis.py:17 +msgid "Red" +msgstr "قرمز" + +#: apps/hadis/admin/hadis.py:18 +msgid "Blue" +msgstr "آبی" + +#: apps/hadis/admin/hadis.py:19 +msgid "Green" +msgstr "سبز" + +#: apps/hadis/admin/hadis.py:20 +msgid "Yellow" +msgstr "زرد" + +#: apps/hadis/admin/hadis.py:21 +msgid "Orange" +msgstr "نارنجی" + +#: apps/hadis/admin/hadis.py:22 +msgid "Purple" +msgstr "بنفش" + +#: apps/hadis/admin/hadis.py:23 +msgid "Pink" +msgstr "صورتی" + +#: apps/hadis/admin/hadis.py:24 +msgid "Brown" +msgstr "قهوه‌ای" + +#: apps/hadis/admin/hadis.py:25 +msgid "Gray" +msgstr "خاکستری" + +#: apps/hadis/admin/hadis.py:26 +msgid "Black" +msgstr "سیاه" + +#: apps/hadis/admin/hadis.py:41 +msgid "Link" +msgstr "پیوند" + +#: apps/hadis/admin/hadis.py:43 apps/hadis/models/hadis.py:22 +msgid "text" +msgstr "متن" + +#: apps/hadis/admin/hadis.py:44 +msgid "link" +msgstr "پیوند" + +#: apps/hadis/admin/hadis.py:76 apps/hadis/models/hadis.py:91 +msgid "Reference Images" +msgstr "تصاویر مرجع" + +#: apps/hadis/admin/hadis.py:109 +msgid "Reference Information" +msgstr "اطلاعات مرجع" + +#: apps/hadis/admin/hadis.py:112 +msgid "Additional Information" +msgstr "اطلاعات تکمیلی" + +#: apps/hadis/admin/hadis.py:125 +msgid "Hadis Overview" +msgstr "نمای کلی حدیث" + +#: apps/hadis/models/category.py:9 +#: apps/hadis/templates/admin/category_index.html:25 +msgid "Shia" +msgstr "شیعه" + +#: apps/hadis/models/category.py:10 +#: apps/hadis/templates/admin/category_index.html:31 +msgid "Sunni" +msgstr "سنی" + +#: apps/hadis/models/category.py:13 +#: apps/hadis/templates/admin/category_index.html:158 +msgid "Quran" +msgstr "قرآن" + +#: apps/hadis/models/category.py:14 +#: apps/hadis/templates/admin/category_index.html:161 +msgid "Hadith" +msgstr "حدیث" + +#: apps/hadis/models/category.py:17 +msgid "Level 1 (Root)" +msgstr "سطح ۱ (ریشه)" + +#: apps/hadis/models/category.py:18 +msgid "Level 2 (Child)" +msgstr "سطح ۲ (فرزند)" + +#: apps/hadis/models/category.py:19 +msgid "Level 3 (Grandchild)" +msgstr "سطح ۳ (نوه)" + +#: apps/hadis/models/category.py:22 +msgid "Category Content Type" +msgstr "نوع محتوای دسته‌بندی" + +#: apps/hadis/models/category.py:23 +msgid "name" +msgstr "نام" + +#: apps/hadis/models/category.py:24 apps/library/models.py:23 +#: apps/podcast/models.py:10 apps/video/models.py:11 +msgid "order" +msgstr "ترتیب" + +#: apps/hadis/models/category.py:34 +msgid "Hadis Category" +msgstr "دسته‌بندی حدیث" + +#: apps/hadis/models/category.py:35 +msgid "Hadis Categories" +msgstr "دسته‌بندی‌های حدیث" + +#: apps/hadis/models/category.py:54 +msgid "Level 1 cannot have content type" +msgstr "سطح ۱ نمی‌تواند نوع محتوا داشته باشد" + +#: apps/hadis/models/category.py:57 +msgid "Level 2 must have content type" +msgstr "سطح ۲ باید نوع محتوا داشته باشد" + +#: apps/hadis/models/category.py:60 +msgid "Level 3 cannot have source/content type" +msgstr "سطح ۳ نمی‌تواند نوع منبع/محتوا داشته باشد" + +#: apps/hadis/models/hadis.py:11 apps/hadis/models/hadis.py:45 +#: apps/hadis/models/transmitter.py:15 apps/library/models.py:22 +#: apps/library/models.py:67 apps/library/models.py:100 +#: apps/podcast/models.py:9 apps/podcast/models.py:76 apps/video/models.py:10 +#: apps/video/models.py:26 apps/video/models.py:83 +msgid "status" +msgstr "وضعیت" + +#: apps/hadis/models/hadis.py:20 +msgid "number" +msgstr "شماره" + +#: apps/hadis/models/hadis.py:23 +msgid "translation" +msgstr "ترجمه" + +#: apps/hadis/models/hadis.py:25 +msgid "category" +msgstr "دسته‌بندی" + +#: apps/hadis/models/hadis.py:27 +msgid "visibility" +msgstr "قابلیت مشاهده" + +#: apps/hadis/models/hadis.py:39 apps/hadis/models/hadis.py:61 +#: apps/hadis/models/transmitter.py:29 +msgid "hadis" +msgstr "حدیث" + +#: apps/hadis/models/hadis.py:40 +msgid "hadises" +msgstr "احادیث" + +#: apps/hadis/models/hadis.py:46 apps/hadis/models/transmitter.py:16 +msgid "Display Status Color" +msgstr "رنگ نمایش وضعیت" + +#: apps/hadis/models/hadis.py:47 +msgid "Status Text" +msgstr "متن وضعیت" + +#: apps/hadis/models/hadis.py:48 +msgid "address" +msgstr "آدرس" + +#: apps/hadis/models/hadis.py:50 +msgid "tags" +msgstr "برچسب‌ها" + +#: apps/hadis/models/hadis.py:51 +msgid "share link" +msgstr "پیوند اشتراک‌گذاری" + +#: apps/hadis/models/hadis.py:52 +msgid "explanation" +msgstr "توضیح" + +#: apps/hadis/models/hadis.py:64 +msgid "book" +msgstr "کتاب" + +#: apps/hadis/models/hadis.py:65 apps/hadis/models/transmitter.py:38 +msgid "description" +msgstr "توضیحات" + +#: apps/hadis/models/hadis.py:69 +msgid "Hadis Reference" +msgstr "مرجع حدیث" + +#: apps/hadis/models/hadis.py:70 +msgid "Hadis References" +msgstr "مراجع حدیث" + +#: apps/hadis/models/hadis.py:80 +msgid "thumbnail" +msgstr "تصویر بندانگشتی" + +#: apps/hadis/models/hadis.py:84 +msgid "Priority" +msgstr "اولویت" + +#: apps/hadis/models/hadis.py:85 +msgid "Priority of the image, lower values mean higher priority." +msgstr "اولویت تصویر، مقادیر کمتر به معنای اولویت بالاتر است." + +#: apps/hadis/models/hadis.py:90 +msgid "Reference Image" +msgstr "تصویر مرجع" + + +#: apps/hadis/models/transmitter.py:18 apps/library/models.py:97 +#: apps/podcast/models.py:66 apps/video/models.py:68 +msgid "image allowed" +msgstr "" + +#: apps/hadis/models/transmitter.py:35 +msgid "transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:41 +msgid "Order" +msgstr "" + +#: apps/hadis/models/transmitter.py:42 +msgid "Order in the chain of transmission" +msgstr "" + +#: apps/hadis/models/transmitter.py:47 +msgid "Hadis Transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:48 +msgid "Hadis Transmitters" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:11 +msgid "Category Tree Editor" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:15 +msgid "" +"Make your category and sort it by drag and drop . and try to edit items by " +"double click." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:62 +msgid "Parent: " +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:70 +msgid "Category Level:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:77 +msgid "L1" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:83 +msgid "L2" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:89 +msgid "L3" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:99 +msgid "Level 1 categories represent source types: Shia or Sunni" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:106 +msgid "" +"Level 2 categories are children of Shia/Sunni with content type: Quran or " +"Hadith" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:113 +msgid "Level 3 categories are children of Quran or Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:127 +msgid "Parent Category:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:130 +msgid "-- Select Parent Category --" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:134 +msgid "Select a parent category or leave empty for top-level category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:916 +msgid "Search for a category..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:925 +msgid "No categories found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:928 +msgid "Searching..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:931 +msgid "Type to search..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1021 +#: apps/hadis/templates/admin/category_index.html:1190 +msgid "Level 1 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1028 +#: apps/hadis/templates/admin/category_index.html:1197 +msgid "Level 2 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1035 +#: apps/hadis/templates/admin/category_index.html:1204 +msgid "Level 3 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1344 +msgid "Level 1 must have a source type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1347 +msgid "Level 2 must have a category type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1423 +#: apps/hadis/templates/admin/category_index.html:1498 +#: apps/hadis/templates/admin/category_index.html:1719 +#: apps/hadis/templates/admin/category_index.html:2119 +msgid "Add Child Category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1992 +msgid "No Items Found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2206 +#: apps/hadis/templates/admin/category_index.html:2214 +msgid "No Quran categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2210 +#: apps/hadis/templates/admin/category_index.html:2215 +msgid "No Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/hadiscategory/change_form.html:28 +msgid "Add Category" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:8 +msgid "Save And Edit Next Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:12 +msgid "Save And Edit Previus Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:16 +msgid "Save And Edit Random" +msgstr "" + +#: apps/library/admin.py:23 +msgid "File Information" +msgstr "" + +#: apps/library/admin.py:26 +msgid "Relations" +msgstr "" + +#: apps/library/admin.py:29 apps/video/admin.py:81 +msgid "Statistics" +msgstr "" + +#: apps/library/admin.py:48 apps/library/models.py:24 +#: apps/library/models.py:128 +msgid "Books" +msgstr "" + +#: apps/library/admin.py:57 utils/keyval_field.py:39 utils/keyval_field.py:59 +#: utils/keyval_field.py:76 utils/keyval_field.py:139 utils/keyval_field.py:167 +#: utils/schema.py:49 +msgid "Title" +msgstr "" + +#: apps/library/admin.py:60 apps/library/admin.py:185 +msgid "Number of Books" +msgstr "" + +#: apps/library/apps.py:8 +msgid "Library" +msgstr "" + +#: apps/library/models.py:10 +msgid "Pinned" +msgstr "" + +#: apps/library/models.py:11 +msgid "Middle Section" +msgstr "" + +#: apps/library/models.py:12 +msgid "Bottom Section" +msgstr "" + +#: apps/library/models.py:15 apps/library/models.py:95 +#: apps/library/models.py:96 +msgid "could be null" +msgstr "" + +#: apps/library/models.py:20 +msgid "Display Position" +msgstr "" + +#: apps/library/models.py:30 +msgid "Book Collection" +msgstr "" + +#: apps/library/models.py:31 +msgid "Book Collections" +msgstr "" + +#: apps/library/models.py:40 +msgid "Pinned Book Collection" +msgstr "" + +#: apps/library/models.py:41 +msgid "Pinned Book Collections" +msgstr "" + +#: apps/library/models.py:50 +msgid "Middle Section Book Collection" +msgstr "" + +#: apps/library/models.py:51 +msgid "Middle Section Book Collections" +msgstr "" + +#: apps/library/models.py:60 +msgid "Bottom Section Book Collection" +msgstr "" + +#: apps/library/models.py:61 +msgid "Bottom Section Book Collections" +msgstr "" + +#: apps/library/models.py:82 +msgid "Category" +msgstr "" + +#: apps/library/models.py:83 config/settings/base.py:552 +msgid "Categories" +msgstr "" + +#: apps/library/models.py:99 +msgid "Number of Pages" +msgstr "" + +#: apps/library/models.py:99 +msgid "eg. 34" +msgstr "" + +#: apps/library/models.py:101 +msgid "Pin to top" +msgstr "" + +#: apps/library/models.py:103 apps/podcast/models.py:68 apps/video/models.py:73 +msgid "categories" +msgstr "" + +#: apps/library/models.py:104 +msgid "collections" +msgstr "" + +#: apps/library/models.py:107 apps/library/models.py:108 +#: apps/podcast/models.py:73 apps/podcast/models.py:74 apps/video/models.py:81 +msgid "view count" +msgstr "" + +#: apps/library/models.py:111 +msgid "File Type" +msgstr "" + +#: apps/library/models.py:127 +msgid "Book" +msgstr "" + +#: apps/podcast/models.py:7 apps/video/models.py:8 +msgid "slug" +msgstr "" + +#: apps/podcast/models.py:18 apps/video/models.py:19 +msgid "Video Category" +msgstr "" + +#: apps/podcast/models.py:19 apps/video/models.py:20 +msgid "Video Categories" +msgstr "" + +#: apps/podcast/models.py:32 apps/podcast/models.py:48 +msgid "podcasts" +msgstr "" + +#: apps/podcast/models.py:39 +msgid "Podcast Collection" +msgstr "" + +#: apps/podcast/models.py:40 +msgid "Podcasts Collections" +msgstr "" + +#: apps/podcast/models.py:45 +msgid "podcast collection" +msgstr "" + +#: apps/podcast/models.py:50 apps/video/models.py:50 +msgid "priority" +msgstr "" + +#: apps/podcast/models.py:56 +msgid "Podcast in Collection" +msgstr "" + +#: apps/podcast/models.py:57 +msgid "Podcasts in Collection" +msgstr "" + +#: apps/podcast/models.py:85 +msgid "Podcast" +msgstr "" + +#: apps/podcast/models.py:86 +msgid "Podcasts" +msgstr "" + +#: apps/quiz/admin/participant.py:35 +msgid "User Email" +msgstr "" + +#: apps/quiz/models/quiz.py:8 +msgid "lesson" +msgstr "" + +#: apps/transaction/admin.py:43 +msgid "Payment Status" +msgstr "" + +#: apps/transaction/models.py:21 +msgid "Updated at" +msgstr "" + +#: apps/transaction/models.py:39 +msgid "phone" +msgstr "" + +#: apps/video/admin.py:31 apps/video/admin.py:55 +msgid "Number of Videos" +msgstr "" + +#: apps/video/admin.py:75 +msgid "Video Information" +msgstr "" + +#: apps/video/models.py:33 +msgid "videos" +msgstr "" + +#: apps/video/models.py:39 +msgid "Video Collection" +msgstr "" + +#: apps/video/models.py:40 +msgid "Video Collections" +msgstr "" + +#: apps/video/models.py:45 +msgid "video collection" +msgstr "" + +#: apps/video/models.py:48 +msgid "video" +msgstr "" + +#: apps/video/models.py:56 +msgid "Video in Collection" +msgstr "" + +#: apps/video/models.py:57 +msgid "Videos in Collection" +msgstr "" + +#: apps/video/models.py:99 +msgid "Video" +msgstr "" + +#: apps/video/models.py:100 +msgid "Videos" +msgstr "" + +#: config/settings/base.py:198 +msgid "English" +msgstr "" + +#: config/settings/base.py:199 +msgid "Persian" +msgstr "" + +#: config/settings/base.py:200 +msgid "Russia" +msgstr "" + +#: config/settings/base.py:323 config/settings/base.py:324 +msgid "Imam Jawad Admin" +msgstr "" + +#: config/settings/base.py:325 +msgid "Imam Jawad Online School" +msgstr "" + +#: config/settings/base.py:329 +msgid "Imam Javad Site" +msgstr "" + +#: config/settings/base.py:409 config/settings/base.py:510 +msgid "Users" +msgstr "" + +#: config/settings/base.py:417 +msgid "Guest Users" +msgstr "" + +#: config/settings/base.py:429 +msgid "Groups" +msgstr "" + +#: config/settings/base.py:453 +msgid "Lessons" +msgstr "" + +#: config/settings/base.py:459 +msgid "Attachments" +msgstr "" + +#: config/settings/base.py:465 +msgid "Glossary" +msgstr "" + +#: config/settings/base.py:471 +msgid "Quizzes" +msgstr "" + +#: config/settings/base.py:489 templates/admin/index.html:8 utils/admin.py:105 +msgid "Dashboard" +msgstr "" + +#: config/settings/base.py:499 +msgid "Authentication" +msgstr "" + +#: config/settings/base.py:521 +msgid "Students" +msgstr "" + +#: config/settings/base.py:533 +msgid "Professors" +msgstr "" + +#: config/settings/base.py:557 +msgid "Certificates" +msgstr "" + +#: config/settings/base.py:579 config/settings/base.py:584 +msgid "Transactions" +msgstr "" + +#: templates/admin/auth/user/change_password.html:10 +msgid "Home" +msgstr "" + +#: templates/admin/auth/user/change_password.html:14 +#: templates/admin/auth/user/change_password.html:52 +msgid "Change password" +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the error below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the errors below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:29 +#, python-format +msgid "Enter a new password for the user %(username)s." +msgstr "" + +#: templates/admin/base_site.html:3 templates/admin/index.html:8 +msgid "Django site admin" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:4 +#: templates/admin/filer/folder/directory_table.html:160 +msgid "Unsorted Uploads" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:13 +#: utils/keyval_field.py:118 +msgid "Name" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:14 +msgid "Owner" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:15 +msgid "Size" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:16 +msgid "Action" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:25 +#: templates/admin/filer/folder/directory_table.html:32 +#: templates/admin/filer/folder/directory_table.html:55 +#: templates/admin/filer/folder/directory_table.html:62 +#, python-format +msgid "Change '%(item_label)s' folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:26 +#: templates/admin/filer/folder/directory_table.html:56 +#: templates/admin/filer/folder/directory_table.html:167 +msgid "Folder Icon" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:73 +#, python-format +msgid "%(counter)s folder" +msgid_plural "%(counter)s folders" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:74 +#, python-format +msgid "%(counter)s file" +msgid_plural "%(counter)s files" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:80 +msgid "Change folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:81 +msgid "Remove folder" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:93 +#: templates/admin/filer/folder/directory_table.html:102 +#: templates/admin/filer/folder/directory_table.html:117 +msgid "Select this file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:105 +#: templates/admin/filer/folder/directory_table.html:120 +#: templates/admin/filer/folder/directory_table.html:146 +#, python-format +msgid "Change '%(item_label)s' details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "disabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "enabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:142 +#, python-format +msgid "Canonical url '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:144 +#, python-format +msgid "Download '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:147 +msgid "Remove file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:154 +msgid "Drop files here or use the \"Upload Files\" button" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:164 +msgid "Drop your file to upload into:" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:174 +msgid "Upload" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:186 +msgid "cancel" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:190 +msgid "Upload success!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:194 +msgid "Upload canceled!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:201 +msgid "previous" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:206 +#, python-format +msgid "Page %(number)s of %(num_pages)s." +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:211 +msgid "next" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +msgid "Click here to select the objects across all pages" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +#, python-format +msgid "Select all %(total_count)s" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:223 +msgid "Clear selection" +msgstr "" + +#: templates/admin/includes/object_delete_summary.html:2 +msgid "Summary" +msgstr "" + +#: templates/docs.html:4 +msgid "Django site adminssss" +msgstr "" + +#: utils/__init__.py:75 +msgid "Development" +msgstr "" + +#: utils/__init__.py:77 +msgid "Production" +msgstr "" + +#: utils/admin.py:106 +msgid "Analytics" +msgstr "" + +#: utils/admin.py:107 +msgid "Settings" +msgstr "" + +#: utils/admin.py:110 +msgid "All" +msgstr "" + +#: utils/admin.py:112 +msgid "New" +msgstr "" + +#: utils/admin.py:222 +msgid "Last week revenue" +msgstr "" + +#: utils/admin.py:240 +msgid "Last week expenses" +msgstr "" + +#: utils/keyval_field.py:16 utils/keyval_field.py:37 utils/keyval_field.py:74 +#: utils/keyval_field.py:94 utils/keyval_field.py:116 utils/keyval_field.py:137 +msgid "Translation" +msgstr "" + +#: utils/keyval_field.py:18 utils/keyval_field.py:96 +msgid "Detail" +msgstr "" + +#: utils/keyval_field.py:23 utils/keyval_field.py:44 utils/keyval_field.py:81 +#: utils/keyval_field.py:101 utils/keyval_field.py:123 +#: utils/keyval_field.py:144 +msgid "Language Code" +msgstr "" + +#: utils/keyval_field.py:57 +msgid "Tour Features" +msgstr "" + +#: utils/keyval_field.py:162 utils/keyval_field.py:172 +msgid "Description" +msgstr "" + + +msgid "Shia" +msgstr "شیعه" + +msgid "Sunni" +msgstr "سنی" + +msgid "Hanafi" +msgstr "حنفی" + +msgid "Maliki" +msgstr "مالکی" + +msgid "Shafi'i" +msgstr "شافعی" + +msgid "Hanbali" +msgstr "حنبلی" + +msgid "Other" +msgstr "سایر" \ No newline at end of file diff --git a/locale/ru/LC_MESSAGES/django.po b/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..c9d15d8 --- /dev/null +++ b/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,1329 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#: config/settings/base.py:485 config/settings/base.py:496 +#: config/settings/base.py:507 config/settings/base.py:518 +#: config/settings/base.py:530 +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:07+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" + +#: apps/account/admin/professor.py:59 apps/account/admin/student.py:46 +msgid "Personal info" +msgstr "" + +#: apps/account/admin/professor.py:60 apps/account/admin/student.py:47 +#: apps/account/admin/user.py:72 apps/account/admin/user.py:99 +#: apps/account/admin/user.py:288 apps/account/admin/user.py:291 +#: templates/admin/filer/folder/directory_table.html:129 +msgid "Permissions" +msgstr "" + +#: apps/account/admin/professor.py:63 apps/account/admin/student.py:50 +#: apps/account/admin/user.py:105 +msgid "Important dates" +msgstr "" + +#: apps/account/admin/user.py:64 apps/account/admin/user.py:166 +msgid "Location" +msgstr "" + +#: apps/account/admin/user.py:68 +msgid "Password" +msgstr "" + +#: apps/account/admin/user.py:80 +msgid "Basic Information" +msgstr "" + +#: apps/account/admin/user.py:87 +msgid "Country & City" +msgstr "" + +#: apps/account/admin/user.py:93 +msgid "Device Information" +msgstr "" + +#: apps/account/admin/user.py:122 apps/account/admin/user.py:147 +msgid "Date Joined" +msgstr "" + +#: apps/account/admin/user.py:126 +msgid "Last Login" +msgstr "" + +#: apps/account/admin/user.py:170 +msgid "password" +msgstr "" + +#: apps/account/admin/user.py:177 apps/course/admin/course.py:159 +msgid "Student" +msgstr "" + +#: apps/account/admin/user.py:197 +msgid "Age" +msgstr "" + +#: apps/account/admin/user.py:217 apps/account/admin/user.py:349 +#: apps/course/admin/course.py:79 config/settings/base.py:447 +#: config/settings/base.py:542 config/settings/base.py:547 +msgid "Courses" +msgstr "" + +#: apps/account/admin/user.py:298 apps/course/admin/course.py:45 +msgid "Course Categories" +msgstr "" + +#: apps/account/admin/user.py:317 apps/course/admin/course.py:64 +msgid "Edit" +msgstr "" + +#: apps/account/admin/user.py:329 apps/course/admin/course.py:312 +msgid "Professor" +msgstr "" + +#: apps/account/models/notification.py:10 apps/hadis/models/hadis.py:10 +#: apps/hadis/models/hadis.py:21 apps/hadis/models/hadis.py:49 +#: apps/podcast/models.py:6 apps/quiz/models/quiz.py:9 apps/video/models.py:7 +msgid "title" +msgstr "" + +#: apps/account/models/notification.py:11 +msgid "message" +msgstr "" + +#: apps/account/models/notification.py:12 +msgid "user" +msgstr "" + +#: apps/account/models/notification.py:13 +msgid "is read" +msgstr "" + +#: apps/account/models/notification.py:18 +msgid "service" +msgstr "" + +#: apps/account/models/notification.py:20 apps/hadis/models/hadis.py:28 +#: apps/hadis/models/hadis.py:54 apps/hadis/models/hadis.py:66 +#: apps/hadis/models/transmitter.py:44 apps/library/models.py:70 +#: apps/library/models.py:114 apps/podcast/models.py:11 +#: apps/podcast/models.py:26 apps/podcast/models.py:78 apps/video/models.py:12 +#: apps/video/models.py:27 apps/video/models.py:85 +msgid "created at" +msgstr "" + +#: apps/account/models/notification.py:21 apps/hadis/models/hadis.py:29 +#: apps/library/models.py:71 apps/library/models.py:115 +#: apps/podcast/models.py:12 apps/podcast/models.py:27 +#: apps/podcast/models.py:79 apps/video/models.py:13 apps/video/models.py:28 +#: apps/video/models.py:86 +msgid "updated at" +msgstr "" + +#: apps/account/models/user.py:34 apps/transaction/models.py:43 +msgid "birthdate" +msgstr "" + +#: apps/account/models/user.py:41 +msgid "Phone Number" +msgstr "" + +#: apps/account/models/user.py:46 apps/transaction/models.py:41 +msgid "Gender" +msgstr "" + +#: apps/account/models/user.py:50 +msgid "City" +msgstr "" + +#: apps/account/models/user.py:51 apps/account/models/user.py:121 +msgid "country" +msgstr "" + +#: apps/account/models/user.py:53 +msgid "device id" +msgstr "" + +#: apps/account/models/user.py:119 +msgid "lat" +msgstr "" + +#: apps/account/models/user.py:120 +msgid "lon" +msgstr "" + +#: apps/account/models/user.py:122 +msgid "city" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:5 +msgid "Driver before template" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:11 +msgid "Active drivers" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:19 +msgid "Inactive drivers" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:27 +msgid "Total points" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:35 +msgid "Total races" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:8 +msgid "Total Actice Users" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:16 +msgid "Total Guest Users" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:22 +msgid "Total Students" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:28 +msgid "Total Professors" +msgstr "" + +#: apps/api/admin.py:27 +msgid "Dimensions" +msgstr "" + +#: apps/api/admin.py:31 +msgid "Preview" +msgstr "" + +#: apps/certificate/admin.py:23 apps/transaction/admin.py:37 +msgid "Timestamps" +msgstr "" + +#: apps/certificate/admin.py:29 apps/course/admin/course.py:273 +#: apps/library/admin.py:20 apps/video/admin.py:78 +msgid "Status" +msgstr "" + +#: apps/certificate/models.py:12 +msgid "pending" +msgstr "" + +#: apps/certificate/models.py:13 +msgid "approved" +msgstr "" + +#: apps/certificate/models.py:14 +msgid "canceled" +msgstr "" + +#: apps/certificate/models.py:20 +msgid "certificate_file" +msgstr "" + +#: apps/course/admin/course.py:97 +msgid "Course Weekly Schedule" +msgstr "" + +#: apps/course/admin/course.py:101 utils/schema.py:47 +msgid "Course Features" +msgstr "" + +#: apps/course/admin/course.py:154 +msgid "Enrollment Details" +msgstr "" + +#: apps/course/admin/course.py:179 apps/course/admin/course.py:293 +msgid "Course" +msgstr "" + +#: apps/course/admin/course.py:191 +msgid "Participant" +msgstr "" + +#: apps/course/admin/course.py:192 +msgid "Participants" +msgstr "" + +#: apps/course/admin/course.py:222 +msgid "Select Student" +msgstr "" + +#: apps/course/admin/course.py:276 +msgid "Course Details" +msgstr "" + +#: apps/course/admin/course.py:280 +msgid "Media" +msgstr "" + +#: apps/course/admin/course.py:283 +msgid "Pricing" +msgstr "" + +#: apps/course/admin/course.py:286 +msgid "Timing & Features" +msgstr "" + +#: apps/course/admin/course.py:301 +msgid "No description" +msgstr "" + +#: apps/course/admin/course.py:316 apps/transaction/admin.py:49 +msgid "Price" +msgstr "" + +#: apps/course/admin/course.py:319 +msgid "Free" +msgstr "" + +#: apps/course/admin/course.py:340 +msgid "View Lessons" +msgstr "" + +#: apps/course/admin/course.py:351 apps/course/admin/course.py:386 +msgid "Course not found" +msgstr "" + +#: apps/course/admin/course.py:376 +msgid "Add Student to Course" +msgstr "" + +#: apps/course/admin/course.py:396 +#, python-brace-format +msgid "Student {student.fullname} is already enrolled in this course" +msgstr "" + +#: apps/course/admin/course.py:405 +#, python-brace-format +msgid "" +"Student {student.fullname} has been successfully added to {course.title}" +msgstr "" + +#: apps/course/admin/course.py:418 +msgid "Change detail action for {}" +msgstr "" + +#: apps/course/admin/lesson.py:59 apps/hadis/admin/hadis.py:148 +msgid "Content" +msgstr "" + +#: apps/course/admin/lesson.py:82 +msgid "Duration" +msgstr "" + +#: apps/course/models/course.py:69 +msgid "Thumbnail" +msgstr "" + +#: apps/course/models/course.py:95 +msgid "Course Final Price" +msgstr "" + +#: apps/course/models/course.py:96 +msgid "" +"This field is automatically calculated based on the discount percentage." +msgstr "" + +#: apps/course/models/course.py:99 +msgid "Timing" +msgstr "" + +#: apps/course/models/course.py:100 +msgid "Course features" +msgstr "" + +#: apps/course/models/course.py:101 apps/course/models/lesson.py:35 +#: apps/course/models/lesson.py:98 apps/transaction/models.py:20 +msgid "Created at" +msgstr "" + +#: apps/course/models/course.py:102 apps/course/models/lesson.py:36 +msgid "Updated At" +msgstr "" + +#: apps/course/models/lesson.py:25 +msgid "Is Active" +msgstr "" + +#: apps/course/templates/course/add_student_form.html:25 +msgid "Submit form" +msgstr "" + +#: apps/hadis/admin/category.py:38 apps/hadis/models/category.py:21 +msgid "Source Type" +msgstr "" + +#: apps/hadis/admin/category.py:69 +msgid "This item can not be modified" +msgstr "" + +#: apps/hadis/admin/category.py:198 +msgid "Category saved successfully. Tree will be reloaded." +msgstr "" + +#: apps/hadis/admin/hadis.py:17 +msgid "Red" +msgstr "" + +#: apps/hadis/admin/hadis.py:18 +msgid "Blue" +msgstr "" + +#: apps/hadis/admin/hadis.py:19 +msgid "Green" +msgstr "" + +#: apps/hadis/admin/hadis.py:20 +msgid "Yellow" +msgstr "" + +#: apps/hadis/admin/hadis.py:21 +msgid "Orange" +msgstr "" + +#: apps/hadis/admin/hadis.py:22 +msgid "Purple" +msgstr "" + +#: apps/hadis/admin/hadis.py:23 +msgid "Pink" +msgstr "" + +#: apps/hadis/admin/hadis.py:24 +msgid "Brown" +msgstr "" + +#: apps/hadis/admin/hadis.py:25 +msgid "Gray" +msgstr "" + +#: apps/hadis/admin/hadis.py:26 +msgid "Black" +msgstr "" + +#: apps/hadis/admin/hadis.py:41 +msgid "Link" +msgstr "" + +#: apps/hadis/admin/hadis.py:43 apps/hadis/models/hadis.py:22 +msgid "text" +msgstr "" + +#: apps/hadis/admin/hadis.py:44 +msgid "link" +msgstr "" + +#: apps/hadis/admin/hadis.py:76 apps/hadis/models/hadis.py:91 +msgid "Reference Images" +msgstr "" + +#: apps/hadis/admin/hadis.py:109 +msgid "Reference Information" +msgstr "" + +#: apps/hadis/admin/hadis.py:112 +msgid "Additional Information" +msgstr "" + +#: apps/hadis/admin/hadis.py:125 +msgid "Hadis Overview" +msgstr "" + +#: apps/hadis/models/category.py:9 +#: apps/hadis/templates/admin/category_index.html:25 +msgid "Shia" +msgstr "" + +#: apps/hadis/models/category.py:10 +#: apps/hadis/templates/admin/category_index.html:31 +msgid "Sunni" +msgstr "" + +#: apps/hadis/models/category.py:13 +#: apps/hadis/templates/admin/category_index.html:158 +msgid "Quran" +msgstr "" + +#: apps/hadis/models/category.py:14 +#: apps/hadis/templates/admin/category_index.html:161 +msgid "Hadith" +msgstr "" + +#: apps/hadis/models/category.py:17 +msgid "Level 1 (Root)" +msgstr "" + +#: apps/hadis/models/category.py:18 +msgid "Level 2 (Child)" +msgstr "" + +#: apps/hadis/models/category.py:19 +msgid "Level 3 (Grandchild)" +msgstr "" + +#: apps/hadis/models/category.py:22 +msgid "Category Content Type" +msgstr "" + +#: apps/hadis/models/category.py:23 +msgid "name" +msgstr "" + +#: apps/hadis/models/category.py:24 apps/library/models.py:23 +#: apps/podcast/models.py:10 apps/video/models.py:11 +msgid "order" +msgstr "" + +#: apps/hadis/models/category.py:34 +msgid "Hadis Category" +msgstr "" + +#: apps/hadis/models/category.py:35 +msgid "Hadis Categories" +msgstr "" + +#: apps/hadis/models/category.py:54 +msgid "Level 1 cannot have content type" +msgstr "" + +#: apps/hadis/models/category.py:57 +msgid "Level 2 must have content type" +msgstr "" + +#: apps/hadis/models/category.py:60 +msgid "Level 3 cannot have source/content type" +msgstr "" + +#: apps/hadis/models/hadis.py:11 apps/hadis/models/hadis.py:45 +#: apps/hadis/models/transmitter.py:15 apps/library/models.py:22 +#: apps/library/models.py:67 apps/library/models.py:100 +#: apps/podcast/models.py:9 apps/podcast/models.py:76 apps/video/models.py:10 +#: apps/video/models.py:26 apps/video/models.py:83 +msgid "status" +msgstr "" + +#: apps/hadis/models/hadis.py:20 +msgid "number" +msgstr "" + +#: apps/hadis/models/hadis.py:23 +msgid "translation" +msgstr "" + +#: apps/hadis/models/hadis.py:25 +msgid "category" +msgstr "" + +#: apps/hadis/models/hadis.py:27 +msgid "visibility" +msgstr "" + +#: apps/hadis/models/hadis.py:39 apps/hadis/models/hadis.py:61 +#: apps/hadis/models/transmitter.py:29 +msgid "hadis" +msgstr "" + +#: apps/hadis/models/hadis.py:40 +msgid "hadises" +msgstr "" + +#: apps/hadis/models/hadis.py:46 apps/hadis/models/transmitter.py:16 +msgid "Display Status Color" +msgstr "" + +#: apps/hadis/models/hadis.py:47 +msgid "Status Text" +msgstr "" + +#: apps/hadis/models/hadis.py:48 +msgid "address" +msgstr "" + +#: apps/hadis/models/hadis.py:50 +msgid "tags" +msgstr "" + +#: apps/hadis/models/hadis.py:51 +msgid "share link" +msgstr "" + +#: apps/hadis/models/hadis.py:52 +msgid "explanation" +msgstr "" + +#: apps/hadis/models/hadis.py:64 +msgid "book" +msgstr "" + +#: apps/hadis/models/hadis.py:65 apps/hadis/models/transmitter.py:38 +msgid "description" +msgstr "" + +#: apps/hadis/models/hadis.py:69 +msgid "Hadis Reference" +msgstr "" + +#: apps/hadis/models/hadis.py:70 +msgid "Hadis References" +msgstr "" + +#: apps/hadis/models/hadis.py:80 +msgid "thumbnail" +msgstr "" + +#: apps/hadis/models/hadis.py:84 +msgid "Priority" +msgstr "" + +#: apps/hadis/models/hadis.py:85 +msgid "Priority of the image, lower values mean higher priority." +msgstr "" + +#: apps/hadis/models/hadis.py:90 +msgid "Reference Image" +msgstr "" + +#: apps/hadis/models/transmitter.py:18 apps/library/models.py:97 +#: apps/podcast/models.py:66 apps/video/models.py:68 +msgid "image allowed" +msgstr "" + +#: apps/hadis/models/transmitter.py:35 +msgid "transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:41 +msgid "Order" +msgstr "" + +#: apps/hadis/models/transmitter.py:42 +msgid "Order in the chain of transmission" +msgstr "" + +#: apps/hadis/models/transmitter.py:47 +msgid "Hadis Transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:48 +msgid "Hadis Transmitters" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:11 +msgid "Category Tree Editor" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:15 +msgid "" +"Make your category and sort it by drag and drop . and try to edit items by " +"double click." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:62 +msgid "Parent: " +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:70 +msgid "Category Level:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:77 +msgid "L1" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:83 +msgid "L2" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:89 +msgid "L3" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:99 +msgid "Level 1 categories represent source types: Shia or Sunni" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:106 +msgid "" +"Level 2 categories are children of Shia/Sunni with content type: Quran or " +"Hadith" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:113 +msgid "Level 3 categories are children of Quran or Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:127 +msgid "Parent Category:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:130 +msgid "-- Select Parent Category --" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:134 +msgid "Select a parent category or leave empty for top-level category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:916 +msgid "Search for a category..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:925 +msgid "No categories found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:928 +msgid "Searching..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:931 +msgid "Type to search..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1021 +#: apps/hadis/templates/admin/category_index.html:1190 +msgid "Level 1 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1028 +#: apps/hadis/templates/admin/category_index.html:1197 +msgid "Level 2 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1035 +#: apps/hadis/templates/admin/category_index.html:1204 +msgid "Level 3 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1344 +msgid "Level 1 must have a source type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1347 +msgid "Level 2 must have a category type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1423 +#: apps/hadis/templates/admin/category_index.html:1498 +#: apps/hadis/templates/admin/category_index.html:1719 +#: apps/hadis/templates/admin/category_index.html:2119 +msgid "Add Child Category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1992 +msgid "No Items Found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2206 +#: apps/hadis/templates/admin/category_index.html:2214 +msgid "No Quran categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2210 +#: apps/hadis/templates/admin/category_index.html:2215 +msgid "No Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/hadiscategory/change_form.html:28 +msgid "Add Category" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:8 +msgid "Save And Edit Next Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:12 +msgid "Save And Edit Previus Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:16 +msgid "Save And Edit Random" +msgstr "" + +#: apps/library/admin.py:23 +msgid "File Information" +msgstr "" + +#: apps/library/admin.py:26 +msgid "Relations" +msgstr "" + +#: apps/library/admin.py:29 apps/video/admin.py:81 +msgid "Statistics" +msgstr "" + +#: apps/library/admin.py:48 apps/library/models.py:24 +#: apps/library/models.py:128 +msgid "Books" +msgstr "" + +#: apps/library/admin.py:57 utils/keyval_field.py:39 utils/keyval_field.py:59 +#: utils/keyval_field.py:76 utils/keyval_field.py:139 utils/keyval_field.py:167 +#: utils/schema.py:49 +msgid "Title" +msgstr "" + +#: apps/library/admin.py:60 apps/library/admin.py:185 +msgid "Number of Books" +msgstr "" + +#: apps/library/apps.py:8 +msgid "Library" +msgstr "" + +#: apps/library/models.py:10 +msgid "Pinned" +msgstr "" + +#: apps/library/models.py:11 +msgid "Middle Section" +msgstr "" + +#: apps/library/models.py:12 +msgid "Bottom Section" +msgstr "" + +#: apps/library/models.py:15 apps/library/models.py:95 +#: apps/library/models.py:96 +msgid "could be null" +msgstr "" + +#: apps/library/models.py:20 +msgid "Display Position" +msgstr "" + +#: apps/library/models.py:30 +msgid "Book Collection" +msgstr "" + +#: apps/library/models.py:31 +msgid "Book Collections" +msgstr "" + +#: apps/library/models.py:40 +msgid "Pinned Book Collection" +msgstr "" + +#: apps/library/models.py:41 +msgid "Pinned Book Collections" +msgstr "" + +#: apps/library/models.py:50 +msgid "Middle Section Book Collection" +msgstr "" + +#: apps/library/models.py:51 +msgid "Middle Section Book Collections" +msgstr "" + +#: apps/library/models.py:60 +msgid "Bottom Section Book Collection" +msgstr "" + +#: apps/library/models.py:61 +msgid "Bottom Section Book Collections" +msgstr "" + +#: apps/library/models.py:82 +msgid "Category" +msgstr "" + +#: apps/library/models.py:83 config/settings/base.py:552 +msgid "Categories" +msgstr "" + +#: apps/library/models.py:99 +msgid "Number of Pages" +msgstr "" + +#: apps/library/models.py:99 +msgid "eg. 34" +msgstr "" + +#: apps/library/models.py:101 +msgid "Pin to top" +msgstr "" + +#: apps/library/models.py:103 apps/podcast/models.py:68 apps/video/models.py:73 +msgid "categories" +msgstr "" + +#: apps/library/models.py:104 +msgid "collections" +msgstr "" + +#: apps/library/models.py:107 apps/library/models.py:108 +#: apps/podcast/models.py:73 apps/podcast/models.py:74 apps/video/models.py:81 +msgid "view count" +msgstr "" + +#: apps/library/models.py:111 +msgid "File Type" +msgstr "" + +#: apps/library/models.py:127 +msgid "Book" +msgstr "" + +#: apps/podcast/models.py:7 apps/video/models.py:8 +msgid "slug" +msgstr "" + +#: apps/podcast/models.py:18 apps/video/models.py:19 +msgid "Video Category" +msgstr "" + +#: apps/podcast/models.py:19 apps/video/models.py:20 +msgid "Video Categories" +msgstr "" + +#: apps/podcast/models.py:32 apps/podcast/models.py:48 +msgid "podcasts" +msgstr "" + +#: apps/podcast/models.py:39 +msgid "Podcast Collection" +msgstr "" + +#: apps/podcast/models.py:40 +msgid "Podcasts Collections" +msgstr "" + +#: apps/podcast/models.py:45 +msgid "podcast collection" +msgstr "" + +#: apps/podcast/models.py:50 apps/video/models.py:50 +msgid "priority" +msgstr "" + +#: apps/podcast/models.py:56 +msgid "Podcast in Collection" +msgstr "" + +#: apps/podcast/models.py:57 +msgid "Podcasts in Collection" +msgstr "" + +#: apps/podcast/models.py:85 +msgid "Podcast" +msgstr "" + +#: apps/podcast/models.py:86 +msgid "Podcasts" +msgstr "" + +#: apps/quiz/admin/participant.py:35 +msgid "User Email" +msgstr "" + +#: apps/quiz/models/quiz.py:8 +msgid "lesson" +msgstr "" + +#: apps/transaction/admin.py:43 +msgid "Payment Status" +msgstr "" + +#: apps/transaction/models.py:21 +msgid "Updated at" +msgstr "" + +#: apps/transaction/models.py:39 +msgid "phone" +msgstr "" + +#: apps/video/admin.py:31 apps/video/admin.py:55 +msgid "Number of Videos" +msgstr "" + +#: apps/video/admin.py:75 +msgid "Video Information" +msgstr "" + +#: apps/video/models.py:33 +msgid "videos" +msgstr "" + +#: apps/video/models.py:39 +msgid "Video Collection" +msgstr "" + +#: apps/video/models.py:40 +msgid "Video Collections" +msgstr "" + +#: apps/video/models.py:45 +msgid "video collection" +msgstr "" + +#: apps/video/models.py:48 +msgid "video" +msgstr "" + +#: apps/video/models.py:56 +msgid "Video in Collection" +msgstr "" + +#: apps/video/models.py:57 +msgid "Videos in Collection" +msgstr "" + +#: apps/video/models.py:99 +msgid "Video" +msgstr "" + +#: apps/video/models.py:100 +msgid "Videos" +msgstr "" + +#: config/settings/base.py:198 +msgid "English" +msgstr "" + +#: config/settings/base.py:199 +msgid "Persian" +msgstr "" + +#: config/settings/base.py:200 +msgid "Russia" +msgstr "" + +#: config/settings/base.py:323 config/settings/base.py:324 +msgid "Imam Jawad Admin" +msgstr "" + +#: config/settings/base.py:325 +msgid "Imam Jawad Online School" +msgstr "" + +#: config/settings/base.py:329 +msgid "Imam Javad Site" +msgstr "" + +#: config/settings/base.py:409 config/settings/base.py:510 +msgid "Users" +msgstr "" + +#: config/settings/base.py:417 +msgid "Guest Users" +msgstr "" + +#: config/settings/base.py:429 +msgid "Groups" +msgstr "" + +#: config/settings/base.py:453 +msgid "Lessons" +msgstr "" + +#: config/settings/base.py:459 +msgid "Attachments" +msgstr "" + +#: config/settings/base.py:465 +msgid "Glossary" +msgstr "" + +#: config/settings/base.py:471 +msgid "Quizzes" +msgstr "" + +#: config/settings/base.py:489 templates/admin/index.html:8 utils/admin.py:105 +msgid "Dashboard" +msgstr "" + +#: config/settings/base.py:499 +msgid "Authentication" +msgstr "" + +#: config/settings/base.py:521 +msgid "Students" +msgstr "" + +#: config/settings/base.py:533 +msgid "Professors" +msgstr "" + +#: config/settings/base.py:557 +msgid "Certificates" +msgstr "" + +#: config/settings/base.py:579 config/settings/base.py:584 +msgid "Transactions" +msgstr "" + +#: templates/admin/auth/user/change_password.html:10 +msgid "Home" +msgstr "" + +#: templates/admin/auth/user/change_password.html:14 +#: templates/admin/auth/user/change_password.html:52 +msgid "Change password" +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the error below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the errors below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:29 +#, python-format +msgid "Enter a new password for the user %(username)s." +msgstr "" + +#: templates/admin/base_site.html:3 templates/admin/index.html:8 +msgid "Django site admin" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:4 +#: templates/admin/filer/folder/directory_table.html:160 +msgid "Unsorted Uploads" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:13 +#: utils/keyval_field.py:118 +msgid "Name" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:14 +msgid "Owner" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:15 +msgid "Size" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:16 +msgid "Action" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:25 +#: templates/admin/filer/folder/directory_table.html:32 +#: templates/admin/filer/folder/directory_table.html:55 +#: templates/admin/filer/folder/directory_table.html:62 +#, python-format +msgid "Change '%(item_label)s' folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:26 +#: templates/admin/filer/folder/directory_table.html:56 +#: templates/admin/filer/folder/directory_table.html:167 +msgid "Folder Icon" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:73 +#, python-format +msgid "%(counter)s folder" +msgid_plural "%(counter)s folders" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:74 +#, python-format +msgid "%(counter)s file" +msgid_plural "%(counter)s files" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:80 +msgid "Change folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:81 +msgid "Remove folder" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:93 +#: templates/admin/filer/folder/directory_table.html:102 +#: templates/admin/filer/folder/directory_table.html:117 +msgid "Select this file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:105 +#: templates/admin/filer/folder/directory_table.html:120 +#: templates/admin/filer/folder/directory_table.html:146 +#, python-format +msgid "Change '%(item_label)s' details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "disabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "enabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:142 +#, python-format +msgid "Canonical url '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:144 +#, python-format +msgid "Download '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:147 +msgid "Remove file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:154 +msgid "Drop files here or use the \"Upload Files\" button" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:164 +msgid "Drop your file to upload into:" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:174 +msgid "Upload" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:186 +msgid "cancel" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:190 +msgid "Upload success!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:194 +msgid "Upload canceled!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:201 +msgid "previous" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:206 +#, python-format +msgid "Page %(number)s of %(num_pages)s." +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:211 +msgid "next" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +msgid "Click here to select the objects across all pages" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +#, python-format +msgid "Select all %(total_count)s" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:223 +msgid "Clear selection" +msgstr "" + +#: templates/admin/includes/object_delete_summary.html:2 +msgid "Summary" +msgstr "" + +#: templates/docs.html:4 +msgid "Django site adminssss" +msgstr "" + +#: utils/__init__.py:75 +msgid "Development" +msgstr "" + +#: utils/__init__.py:77 +msgid "Production" +msgstr "" + +#: utils/admin.py:106 +msgid "Analytics" +msgstr "" + +#: utils/admin.py:107 +msgid "Settings" +msgstr "" + +#: utils/admin.py:110 +msgid "All" +msgstr "" + +#: utils/admin.py:112 +msgid "New" +msgstr "" + +#: utils/admin.py:222 +msgid "Last week revenue" +msgstr "" + +#: utils/admin.py:240 +msgid "Last week expenses" +msgstr "" + +#: utils/keyval_field.py:16 utils/keyval_field.py:37 utils/keyval_field.py:74 +#: utils/keyval_field.py:94 utils/keyval_field.py:116 utils/keyval_field.py:137 +msgid "Translation" +msgstr "" + +#: utils/keyval_field.py:18 utils/keyval_field.py:96 +msgid "Detail" +msgstr "" + +#: utils/keyval_field.py:23 utils/keyval_field.py:44 utils/keyval_field.py:81 +#: utils/keyval_field.py:101 utils/keyval_field.py:123 +#: utils/keyval_field.py:144 +msgid "Language Code" +msgstr "" + +#: utils/keyval_field.py:57 +msgid "Tour Features" +msgstr "" + +#: utils/keyval_field.py:162 utils/keyval_field.py:172 +msgid "Description" +msgstr "" + + +msgid "Shia" +msgstr "Шиит" + +msgid "Sunni" +msgstr "Суннит" + +msgid "Hanafi" +msgstr "Ханафит" + +msgid "Maliki" +msgstr "Маликит" + +msgid "Shafi'i" +msgstr "Шафиит" + +msgid "Hanbali" +msgstr "Ханбалит" + +msgid "Other" +msgstr "Другой" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2bf1970 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,964 @@ +{ + "name": "backend3", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend3", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "tailwindcss": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.9" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", + "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", + "integrity": "sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz", + "integrity": "sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", + "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", + "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "engines": { + "node": ">= 14" + } + } + } + } + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6eaa1fd --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "scripts": { + "tailwind:build": "tailwindcss -i utils/styles.css -o static/css/styles.css --minify", + "tailwind:watch": "tailwindcss -i utils/styles.css -o static/css/styles.css --watch --minify" + }, + "dependencies": { + "tailwindcss": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.9" + }, + "name": "backend3", + "version": "1.0.0", + "main": "tailwind.config.js", + "keywords": [], + "author": "", + "license": "ISC", + "description": "" + } + \ No newline at end of file diff --git a/prayer_times_calculation_guide.html b/prayer_times_calculation_guide.html new file mode 100644 index 0000000..e9c38e4 --- /dev/null +++ b/prayer_times_calculation_guide.html @@ -0,0 +1,678 @@ + + + + + + راهنمای محاسبه اوقات شرعی - PrayTimes Class + + + +
+
+

راهنمای محاسبه اوقات شرعی

+

تحلیل کامل کلاس PrayTimes و الگوریتم‌های محاسبه اوقات اذان

+
+ +
+ +
+

مرحله اول: معرفی کلی سیستم

+ +

کلاس PrayTimes یک سیستم پیشرفته برای محاسبه اوقات شرعی است که بر اساس موقعیت جغرافیایی، تاریخ و روش‌های مختلف محاسبه عمل می‌کند.

+ +

ویژگی‌های کلیدی:

+
    +
  • محاسبه دقیق بر اساس Julian Date
  • +
  • پشتیبانی از روش‌های مختلف محاسبه (MWL, ISNA, Karachi و...)
  • +
  • تنظیم خودکار برای عرض‌های جغرافیایی بالا
  • +
  • خروجی به صورت اعداد اعشاری (ساعت از روز)
  • +
+ +
+
// نمونه ایجاد instance از کلاس PrayTimes
+PrayTimes prayTimes = PrayTimes(
+    calendar: DateTime(2024, 3, 15),           // تاریخ
+    coordinates: Coordinates(                   // موقعیت جغرافیایی
+        latitude: 35.6892,                     // عرض جغرافیایی تهران
+        longitude: 51.3890                     // طول جغرافیایی تهران
+    ),
+    method: CalculationMethod.Tehran,           // روش محاسبه
+    highLatitudesMethod: HighLatitudesMethod.NightMiddle,
+    in12Hours: false                           // فرمت 24 ساعته
+);
+
+ +
+ هر زمان اذان به صورت یک عدد اعشاری بین 0 تا 24 نمایش داده می‌شود که نشان‌دهنده ساعت از ابتدای روز است. +
+
+ + +
+

مرحله دوم: ساختار کلاس و متدهای اصلی

+ +

متغیرهای اصلی کلاس:

+
+
class PrayTimes {
+    // اوقات به صورت اعداد اعشاری (0-24)
+    late double imsak;      // امساک
+    late double fajr;       // فجر
+    late double sunrise;    // طلوع آفتاب
+    late double dhuhr;      // ظهر
+    late double asr;        // عصر
+    late double sunset;     // غروب آفتاب
+    late double maghrib;    // مغرب
+    late double isha;       // عشا
+    late double midnight;   // نیمه شب
+
+    List<double> allTimes = [];        // لیست تمام اوقات
+    List<DateTime?> allinDateTime = []; // تبدیل به DateTime
+}
+
+ +

مراحل محاسبه در Constructor:

+ +
+ 1 + محاسبه Julian Date: +
+ jdate = julian(year, month, day) - longitude / (15.0 * 24.0) +
+

Julian Date یک سیستم شمارش روزها از تاریخ مشخصی است که در نجوم استفاده می‌شود.

+
+ +
+ 2 + محاسبه اوقات اولیه: +
+
// محاسبه هر یک از اوقات بر اساس زاویه خورشید
+double fajr = sunAngleTime(jdate, method.fajr, _DEFAULT_FAJR, true, coordinates);
+double sunrise = sunAngleTime(jdate, riseSetAngle(coordinates), _DEFAULT_SUNRISE, true, coordinates);
+double dhuhr = midDay(jdate, _DEFAULT_DHUHR);
+double asr = asrTime(jdate, asrMethod.asrFactor, _DEFAULT_ASR, coordinates);
+double sunset = sunAngleTime(jdate, riseSetAngle(coordinates), _DEFAULT_SUNSET, false, coordinates);
+double maghrib = sunAngleTime(jdate, method.maghrib, _DEFAULT_MAGHRIB, false, coordinates);
+double isha = sunAngleTime(jdate, method.isha, _DEFAULT_ISHA, false, coordinates);
+
+
+ +
+ 3 + تنظیم TimeZone: +
+
// محاسبه offset برای timezone و longitude
+double offset = DateTime.now().timeZoneOffset.inMilliseconds / (60 * 60 * 1000.0);
+double addToAll = offset - coordinates.longitude / 15.0;
+
+// اعمال offset به تمام اوقات
+fajr += addToAll;
+sunrise += addToAll;
+dhuhr += addToAll;
+// ... سایر اوقات
+
+
+
+ + +
+

مرحله سوم: الگوریتم‌های محاسبه دقیق

+ +

1. محاسبه موقعیت خورشید (sunPosition):

+
+
DeclEqt sunPosition(double jd) {
+    double D = jd - 2451545.0;                    // روزهای گذشته از epoch
+    double g = (357.529 + 0.98560028 * D) % 360;  // Mean anomaly
+    double q = (280.459 + 0.98564736 * D) % 360;  // Mean longitude
+
+    // محاسبه True longitude
+    double L = (q + 1.915 * sin(dtr(g)) + 0.020 * sin(dtr(2.0 * g))) % 360;
+
+    double e = 23.439 - 0.00000036 * D;           // Obliquity of ecliptic
+
+    // محاسبه Right Ascension و Equation of Time
+    double RA = rtd(atan2(cos(dtr(e)) * sin(dtr(L)), cos(dtr(L)))) / 15.0;
+    double eqt = q / 15.0 - fixHour(RA);
+    double decl = asin(sin(dtr(e)) * sin(dtr(L))); // Declination
+
+    return DeclEqt(decl, eqt);
+}
+
+ +

2. محاسبه زمان بر اساس زاویه خورشید (sunAngleTime):

+
+
double sunAngleTime(double jdate, MinuteOrAngleDouble angle, double time,
+                   bool ccw, Coordinates coordinates) {
+    double decl = sunPosition(jdate + time).declination;
+    double noon = dtr(midDay(jdate, time));
+
+    // فرمول اصلی محاسبه زمان بر اساس زاویه
+    double t = acos((-sin(dtr(angle.value)) -
+                    sin(decl) * sin(dtr(coordinates.latitude))) /
+                   (cos(decl) * cos(dtr(coordinates.latitude)))) / 15.0;
+
+    return rtd(noon + (ccw ? -t : t));
+}
+
+ +
+ پارامتر ccw (Counter Clock Wise) تعیین می‌کند که آیا زمان قبل از ظهر (true) یا بعد از ظهر (false) محاسبه شود. +
+ +

3. محاسبه زمان عصر (asrTime):

+
+
double asrTime(double jdate, double factor, double time, Coordinates coordinates) {
+    double decl = sunPosition(jdate + time).declination;
+
+    // محاسبه زاویه بر اساس فاکتور عصر (1 برای استاندارد، 2 برای حنفی)
+    double angle = -atan(1 / (factor + tan(abs(dtr(coordinates.latitude) - decl))));
+
+    return sunAngleTime(jdate, MinuteOrAngleDouble.deg(rtd(angle)), time, false, coordinates);
+}
+
+
+ + +
+

مرحله چهارم: روش‌های محاسبه مختلف

+ +

هر روش محاسبه دارای زوایای مختلفی برای فجر، مغرب و عشا است:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
روش محاسبهزاویه فجرزاویه عشامغربکاربرد
MWL (Muslim World League)18°17°غروب + دقیقهاروپا، آمریکا
ISNA (North America)15°15°غروب + دقیقهآمریکای شمالی
University of Karachi18°18°غروب + دقیقهپاکستان، هند
Umm Al-Qura (مکه)18.5°90 دقیقه بعد مغربغروب + دقیقهعربستان سعودی
Egyptian Authority19.5°17.5°غروب + دقیقهمصر، خاورمیانه
Institute of Tehran17.7°14°4.5°ایران
Ithna Ashari16°14°شیعه
+ +
+
// نمونه تعریف روش محاسبه در enum
+enum CalculationMethod {
+    IthnaAshari,    // اثنی عشری
+    Karachi,        // کراچی
+    NorthAmerica,   // آمریکای شمالی
+    MWL,            // رابطه جهانی اسلامی
+    UmmAlQura,      // ام القری
+    Egyptian,       // مصری
+    Tehran,         // تهران
+}
+
+
+ + +
+

مرحله پنجم: تنظیمات عرض‌های جغرافیایی بالا

+ +

در عرض‌های جغرافیایی بالا (بالای 49 درجه)، ممکن است برخی اوقات قابل محاسبه نباشند. برای حل این مشکل از روش‌های مختلفی استفاده می‌شود:

+ +

روش‌های تنظیم:

+ +
+ 1 + NightMiddle (وسط شب): +

فجر و عشا بر اساس نیمه شب محاسبه می‌شوند.

+
+ portion = 1/2 * nightTime +
+
+ +
+ 2 + AngleBased (بر اساس زاویه): +

بر اساس زاویه مشخص شده محاسبه می‌شود.

+
+ portion = angle/60 * nightTime +
+
+ +
+ 3 + OneSeventh (یک هفتم شب): +

یک هفتم از طول شب استفاده می‌شود.

+
+ portion = 1/7 * nightTime +
+
+ +
+
// تنظیم اوقات برای عرض‌های جغرافیایی بالا
+if (highLatitudesMethod != HighLatitudesMethod.None) {
+    double nightTime = timeDiff(sunset, sunrise);
+
+    fajr = adjustHLTime(highLatitudesMethod, fajr, sunrise,
+                       method.fajr.value, nightTime, true);
+    isha = adjustHLTime(highLatitudesMethod, isha, sunset,
+                       method.isha.value, nightTime, false);
+}
+
+ +
+ در کد پروژه، اگر عرض جغرافیایی کمتر از 49 درجه باشد، به طور خودکار از روش NightMiddle استفاده می‌شود. +
+
+ + +
+

مرحله ششم: تبدیل اعداد اعشاری به زمان

+ +

خروجی کلاس PrayTimes اعداد اعشاری هستند که نشان‌دهنده ساعت از ابتدای روز می‌باشند. این اعداد باید به فرمت زمان قابل خواندن تبدیل شوند.

+ +

نحوه تبدیل:

+ +
+ 1 + جدا کردن ساعت و دقیقه: +
+
String get floatToTime24 {
+    var time = this;
+    if (time == null || time.isNaN) return "----";
+
+    time = _fixHour(time + 0.5 / 60.0); // اضافه کردن 0.5 دقیقه برای گرد کردن
+    int hours = (time).floor();          // بخش صحیح = ساعت
+    double minutes = ((time - hours) * 60.0).floorToDouble(); // بخش اعشاری × 60 = دقیقه
+
+    // فرمت کردن با صفر اضافی
+    return "${hours.toString().padLeft(2, '0')}:${minutes.round().toString().padLeft(2, '0')}";
+}
+
+
+ +
+ 2 + مثال عملی: +
+ اگر fajr = 5.25 باشد:
+ ساعت = 5 (بخش صحیح)
+ دقیقه = 0.25 × 60 = 15
+ نتیجه = "05:15" +
+
+ +

تبدیل به فرمت 12 ساعته:

+
+
String get floatToTime12 {
+    // ... محاسبه ساعت و دقیقه مشابه بالا
+
+    if (hours >= 12 && hours < 24) {
+        var hourss = hours - 12;
+        if (hourss == 0) hourss = 12;  // 12 PM نه 0 PM
+        // فرمت کردن...
+    } else {
+        var hourss = hours;
+        if (hourss == 0) hourss = 12;  // 12 AM نه 0 AM
+        // فرمت کردن...
+    }
+}
+
+String get amPm {
+    int hours = (this).floor();
+    return hours >= 12 && hours < 24 ? 'PM' : 'AM';
+}
+
+
+ + +
+

مرحله هفتم: نمونه کد کامل و کاربردی

+ +

نمونه استفاده کامل:

+
+
// تعریف موقعیت جغرافیایی تهران
+Coordinates tehranCoords = Coordinates(
+    latitude: 35.6892,
+    longitude: 51.3890,
+    elevation: 1200  // ارتفاع از سطح دریا (متر)
+);
+
+// ایجاد instance برای تاریخ امروز
+PrayTimes prayTimes = PrayTimes(
+    calendar: DateTime.now(),
+    coordinates: tehranCoords,
+    method: CalculationMethod.Tehran,
+    highLatitudesMethod: HighLatitudesMethod.NightMiddle,
+    in12Hours: false
+);
+
+// دریافت اوقات
+print("فجر: ${prayTimes.fajr.floatToTime24}");      // مثال: "05:15"
+print("طلوع: ${prayTimes.sunrise.floatToTime24}");   // مثال: "06:45"
+print("ظهر: ${prayTimes.dhuhr.floatToTime24}");      // مثال: "12:30"
+print("عصر: ${prayTimes.asr.floatToTime24}");        // مثال: "15:20"
+print("مغرب: ${prayTimes.maghrib.floatToTime24}");   // مثال: "18:15"
+print("عشا: ${prayTimes.isha.floatToTime24}");       // مثال: "19:45"
+
+// تبدیل به DateTime برای استفاده در برنامه
+DateTime fajrDateTime = DateTime(
+    prayTimes.calendar.year,
+    prayTimes.calendar.month,
+    prayTimes.calendar.day
+).add(Duration(
+    hours: prayTimes.fajr.floor(),
+    minutes: ((prayTimes.fajr - prayTimes.fajr.floor()) * 60).round()
+));
+
+ +

ایجاد لیست اوقات برای چندین روز:

+
+
List<PrayTimeModel> getPrayTimesForMonth(DateTime startDate, Coordinates coords) {
+    List<PrayTimeModel> allPrayTimes = [];
+
+    for (int i = 0; i < 30; i++) {
+        DateTime currentDate = startDate.add(Duration(days: i));
+
+        PrayTimes pt = PrayTimes(
+            calendar: currentDate,
+            coordinates: coords,
+            method: CalculationMethod.Tehran,
+            highLatitudesMethod: HighLatitudesMethod.NightMiddle,
+            in12Hours: false,
+        );
+
+        // اضافه کردن هر وقت به لیست
+        for (int j = 0; j < pt.allTimes.length; j++) {
+            allPrayTimes.add(PrayTimeModel(
+                enumTime: EnumTime.values[j],
+                name: EnumTime.values[j].name,
+                timeInString: pt.allTimes[j].floatToTime24,
+                dateTime: currentDate.add(Duration(
+                    hours: pt.allTimes[j].floor(),
+                    minutes: ((pt.allTimes[j] - pt.allTimes[j].floor()) * 60).round()
+                )),
+            ));
+        }
+    }
+
+    return allPrayTimes;
+}
+
+
+ + +
+

خلاصه و نکات مهم

+ +

نکات کلیدی:

+
    +
  • دقت محاسبات: تمام محاسبات بر اساس فرمول‌های نجومی دقیق انجام می‌شود
  • +
  • انعطاف‌پذیری: پشتیبانی از روش‌های مختلف محاسبه برای مناطق مختلف جهان
  • +
  • تنظیم خودکار: تنظیم خودکار برای عرض‌های جغرافیایی بالا
  • +
  • خروجی استاندارد: خروجی به صورت اعداد اعشاری قابل تبدیل به هر فرمت
  • +
+ +
+ برای استفاده بهینه، توصیه می‌شود اوقات را برای چندین روز آینده محاسبه و ذخیره کنید تا از محاسبات مکرر جلوگیری شود. +
+ +
+ دقت کنید که تغییر موقعیت جغرافیایی یا روش محاسبه نیاز به محاسبه مجدد تمام اوقات دارد. +
+ +

منابع و مراجع:

+
    +
  • الگوریتم‌های نجومی برای محاسبه موقعیت خورشید
  • +
  • استانداردهای بین‌المللی اوقات شرعی
  • +
  • فرمول‌های ریاضی برای تبدیل مختصات جغرافیایی
  • +
+
+
+
+ + diff --git a/requirements.txt b/requirements.txt index 58dd0e9..f0c05d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,85 +1,133 @@ +amqp==5.2.0 asgiref==3.8.1 +async-timeout==4.0.3 +attrs==23.2.0 +Babel==2.15.0 +beautifulsoup4==4.12.3 +billiard==3.6.4.0 +cachetools==5.5.2 +celery==5.2.1 certifi==2024.2.2 +cffi==1.16.0 +chardet==5.2.0 charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +colorama==0.4.6 +conditional==2.0 +cssselect2==0.8.0 +Deprecated==1.2.18 diff-match-patch==20230430 +Django>=4.2.0 django-ajax-datatable==4.5.0 +django-allauth==65.3.0 django-autoslug==1.9.9 +django-clone==5.3.3 django-cors-headers==4.3.1 +django-countries==7.2.1 +django-crispy-forms==1.11.0 django-debug-toolbar==4.3.0 +django-dynamic-preferences==1.16.0 django-environ==0.11.2 +django-filer==3.3.1 django-filter==2.4.0 django-import-export==4.0.3 +django-js-asset==1.2.2 +django-money==3.5.2 +django-mptt==0.16.0 django-multiselectfield==0.1.12 +django-parler==2.2 +django-paypal==1.1.2 django-phonenumber-field==5.2.0 -django-recaptcha==2.0.6 -django==3.2.4 -djangorestframework==3.15.1 -drf-yasg==1.21.7 +django-polymorphic==3.0.0 +django-recaptcha==4.1.0 +django-redis==5.4.0 +django-reset-migrations==0.4.0 +django-rosetta==0.9.6 +django-unfold==0.54.0 +djangorestframework==3.16.0 +drf-yasg==1.21.10 +easy-thumbnails==2.10 +exceptiongroup==1.2.1 +geographiclib==2.0 +geopy==2.3.0 +# guardian==0.2.3 +django-guardian==2.4.0 gunicorn==22.0.0 +h11==0.14.0 idna==3.7 inflection==0.5.1 +Jinja2==3.1.6 +kombu==5.3.7 +lxml==5.3.1 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +oauthlib==3.1.0 +outcome==1.3.0.post0 packaging==24.0 +paypal==1.2.5 +persisting-theory==1.0 phonenumbers==8.13.37 -pillow==10.3.0 +pillow==11.0.0 +polib==1.2.0 +prompt_toolkit==3.0.45 psycopg2-binary==2.9.9 -pytz==2024.1 -geopy==2.3.0 -pyyaml==6.0.1 -requests==2.32.1 -sqlparse==0.5.0 -tablib==3.5.0 -typing-extensions==4.11.0 -uritemplate==4.1.1 -urllib3==2.2.1 -redis==4.3.4 -django-redis==5.4.0 -celery==5.2.1 -sentry-sdk==1.6.0 -outcome==1.3.0.post0 -prompt-toolkit==3.0.45 py-moneyed==3.0 pycparser==2.22 -pysocks==1.7.1 +Pygments==2.15.0 +# PyJWT==2.0.1 +PySocks==1.7.1 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 +python-slugify==8.0.1 +pytz==2025.2 +PyYAML==6.0.2 +redis==4.3.4 +reportlab==4.2.5 +requests==2.32.1 +requests-oauthlib==1.3.0 +rich==13.7.0 +ruamel.yaml==0.18.6 +ruamel.yaml.clib==0.2.12 selenium==4.21.0 -setuptools==70.0.0 +sentry-sdk==1.6.0 six==1.16.0 sniffio==1.3.1 sortedcontainers==2.4.0 soupsieve==2.5 -trio-websocket==0.11.1 +sqlparse==0.5.0 +svglib==1.5.1 +tablib==3.5.0 +text-unidecode==1.3 +tinycss2==1.4.0 trio==0.25.1 -django-mptt==0.12.0 +trio-websocket==0.11.1 +typing_extensions==4.13.0 tzdata==2024.1 +unicode-slugify==0.1.3 +Unidecode==1.1.2 +uritemplate==4.1.1 +urllib3==2.2.1 vine==5.1.0 wcwidth==0.2.13 webdriver-manager==4.0.1 +webencodings==0.5.1 +whitenoise==6.9.0 +wrapt==1.16.0 wsproto==1.2.0 -django-money==3.5.2 -exceptiongroup==1.2.1 -h11==0.14.0 -kombu==5.3.7 -amqp==5.2.0 -async-timeout==4.0.3 -attrs==23.2.0 -babel==2.15.0 -beautifulsoup4==4.12.3 -python-slugify==8.0.1 -billiard==3.6.4.0 -cffi==1.16.0 -click-didyoumean==0.3.1 -click-plugins==1.1.1 -click-repl==0.3.0 -click==8.1.7 -colorama==0.4.6 -django-dynamic-preferences==1.16.0 -unidecode +jdatetime==4.1.0 +kavenegar==1.1.2 +geopy==2.3.0 +geoip2==4.7.0 +# firebase-admin==6.2.0 +google-auth==2.6.0 https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-seo.git/archive/master.zip -https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-filer.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-language.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-category.git/archive/master.zip -https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/django-modules/FastFileManager.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-filer.git/archive/master.zip diff --git a/runner.sh b/runner.sh index 73206b5..29a4c82 100644 --- a/runner.sh +++ b/runner.sh @@ -7,6 +7,6 @@ if [ "$1" == "--dev" ]; then else echo "Run Production docker" - git pull origin master + git pull origin develop DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.prod.yml up -d --build -fi \ No newline at end of file +fi diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..42c8bf3 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,169 @@ +# Скрипты для заполнения данными приложения Hadis + +Этот каталог содержит скрипты для создания и управления тестовыми данными для приложения Hadis. + +## Файлы + +### Основные скрипты + +- **`seed_hadis_data.py`** - Основной скрипт для создания тестовых данных +- **`clear_hadis_data.py`** - Скрипт для очистки созданных данных +- **`README.md`** - Этот файл с документацией + +### Ресурсы + +- **`seed_images/`** - Каталог с изображениями для обложек книг + - `book1.png`, `book2.png`, `book3.png`, `book4.png` +- **`test.xmind`** - Файл XMind для категорий хадисов + +## Использование + +### Создание тестовых данных + +```bash +# Создать данные без очистки существующих +python scripts/seed_hadis_data.py + +# Создать данные с очисткой существующих (ОСТОРОЖНО!) +python scripts/seed_hadis_data.py --clear + +# Создать данные без очистки (явно) +python scripts/seed_hadis_data.py --no-clear +``` + +### Очистка данных + +```bash +# Очистить все данные хадисов и связанные данные библиотеки +python scripts/clear_hadis_data.py + +# Очистить только данные хадисов, оставить библиотеку +python scripts/clear_hadis_data.py --hadis-only + +# Принудительная очистка без подтверждения +python scripts/clear_hadis_data.py --force +``` + +## Создаваемые данные + +### Модели Hadis + +1. **HadisSect** (Секты) + - Шииты-двунадесятники + - Сунниты + +2. **HadisStatus** (Статусы хадисов) + - Достоверный, Хороший, Слабый, Выдуманный, и др. + +3. **HadisTag** (Теги) + - Поклонение, Молитва, Пост, Хадж, Закят, и др. + +4. **HadisCategory** (Категории) - Иерархическая структура + - **Коран**: Толкование Корана, Аяты предписаний, и др. + - **Хадисы**: Книга молитвы, Книга поста, Книга хаджа, и др. + +5. **Transmitters** (Передатчики) + - Известные мухаддисы и имамы + +6. **Hadis** (Хадисы) + - Реалистичные тексты хадисов на русском языке + - Переводы на персидском и английском + - Объяснения и комментарии + +7. **HadisTransmitter** (Цепочки передачи) + - Цепочки передатчиков для каждого хадиса + - Включая пропуски в цепочках + +8. **HadisReference** (Ссылки) + - Связи хадисов с книгами + +9. **ReferenceImage** (Изображения ссылок) + - Изображения для ссылок на источники + +### Модели Library + +1. **Book** (Книги) + - Аль-Кафи, Сахих аль-Бухари, и др. + - С обложками из seed_images + +2. **Category** (Категории библиотеки) + - Книги хадисов, Книги фикха, и др. + +3. **BookCollection** (Коллекции книг) + - Шиитские книги хадисов, Суннитские книги хадисов + +## Особенности + +### Реалистичные данные +- Все тексты на русском языке +- Аутентичные названия книг и имена передатчиков +- Правильная иерархия категорий +- Реалистичные цепочки передачи + +### Связи между моделями +- Правильные foreign key связи +- Many-to-many отношения для тегов +- Иерархические структуры (MPTT) для категорий + +### Файлы и изображения +- XMind файлы для категорий +- Изображения обложек для книг +- Изображения для ссылок + +### Безопасность +- Транзакционная безопасность +- Возможность отката при ошибках +- Подтверждение перед удалением данных + +## Структура данных + +``` +HadisSect (2 записи) +├── HadisCategory (иерархическая структура) +│ ├── Quran categories (4 основные + дочерние) +│ └── Hadith categories (7 основных + дочерние) +│ +├── Hadis (2-4 хадиса на категорию) +│ ├── HadisTransmitter (цепочки 3-6 передатчиков) +│ ├── HadisReference (1-3 ссылки на книги) +│ └── ReferenceImage (изображения для ссылок) +│ +├── HadisStatus (7 статусов) +├── HadisTag (30+ тегов) +└── Transmitters (10 известных передатчиков) + +Library Models: +├── Book (4 книги с обложками) +├── Category (5 категорий) +└── BookCollection (3 коллекции) +``` + +## Тестирование API + +После создания данных можно тестировать API: + +```bash +# Список сект +curl -X GET "http://localhost:8000/api/hadis/sects/" + +# Категории по секте +curl -X GET "http://localhost:8000/api/hadis/sect/1/categories/" + +# Хадисы по категории +curl -X GET "http://localhost:8000/api/hadis/category/1/hadis/" +``` + +## Требования + +- Django проект настроен и работает +- Все зависимости установлены +- База данных мигрирована +- Файлы seed_images и test.xmind присутствуют + +## Примечания + +- Скрипт создает данные на русском языке +- Используются реалистичные исламские термины и имена +- Данные подходят для демонстрации и тестирования +- Можно безопасно запускать несколько раз +- Поддерживается частичная очистка данных diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/clear_hadis_data.py b/scripts/clear_hadis_data.py new file mode 100644 index 0000000..10d727d --- /dev/null +++ b/scripts/clear_hadis_data.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +Script to clear existing hadis data created by seeding scripts. +This script safely removes all hadis-related data while preserving +other application data. +""" + +import os +import sys +import django +from pathlib import Path +from django.db import transaction + +# Setup Django environment +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.append(str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +# Import models after Django setup +from apps.hadis.models import ( + HadisSect, HadisCategory, HadisStatus, HadisTag, Hadis, + Transmitters, HadisTransmitter, HadisReference, ReferenceImage +) +from apps.library.models import Book, Category as LibraryCategory, BookCollection + + +class HadisDataCleaner: + """Class to safely clear hadis data""" + + def __init__(self): + pass + + def show_current_data(self): + """Show current data counts""" + print("=== ТЕКУЩИЕ ДАННЫЕ ===") + print(f"HadisSect: {HadisSect.objects.count()}") + print(f"HadisCategory: {HadisCategory.objects.count()}") + print(f"HadisStatus: {HadisStatus.objects.count()}") + print(f"HadisTag: {HadisTag.objects.count()}") + print(f"Hadis: {Hadis.objects.count()}") + print(f"Transmitters: {Transmitters.objects.count()}") + print(f"HadisTransmitter: {HadisTransmitter.objects.count()}") + print(f"HadisReference: {HadisReference.objects.count()}") + print(f"ReferenceImage: {ReferenceImage.objects.count()}") + print(f"Books: {Book.objects.count()}") + print(f"Library Categories: {LibraryCategory.objects.count()}") + print(f"Book Collections: {BookCollection.objects.count()}") + + # Show sample data + print("\n=== ОБРАЗЦЫ ДАННЫХ ===") + if HadisSect.objects.exists(): + print("HadisSect samples:") + for sect in HadisSect.objects.all()[:3]: + print(f" - {sect.title}") + + if HadisStatus.objects.exists(): + print("HadisStatus samples:") + for status in HadisStatus.objects.all()[:3]: + print(f" - {status.title}") + + if HadisTag.objects.exists(): + print("HadisTag samples:") + for tag in HadisTag.objects.all()[:5]: + print(f" - {tag.title}") + + if Transmitters.objects.exists(): + print("Transmitters samples:") + for trans in Transmitters.objects.all()[:3]: + print(f" - {trans.full_name}") + + if Book.objects.exists(): + print("Books samples:") + for book in Book.objects.all()[:3]: + print(f" - {book.title}") + + @transaction.atomic + def clear_all_hadis_data(self): + """Clear all hadis-related data""" + print("\n=== ОЧИСТКА ДАННЫХ ХАДИСОВ ===") + + # Clear in reverse dependency order + print("Удаление ReferenceImage...") + count = ReferenceImage.objects.count() + ReferenceImage.objects.all().delete() + print(f" Удалено {count} записей ReferenceImage") + + print("Удаление HadisReference...") + count = HadisReference.objects.count() + HadisReference.objects.all().delete() + print(f" Удалено {count} записей HadisReference") + + print("Удаление HadisTransmitter...") + count = HadisTransmitter.objects.count() + HadisTransmitter.objects.all().delete() + print(f" Удалено {count} записей HadisTransmitter") + + print("Удаление Hadis...") + count = Hadis.objects.count() + Hadis.objects.all().delete() + print(f" Удалено {count} записей Hadis") + + print("Удаление HadisCategory...") + count = HadisCategory.objects.count() + HadisCategory.objects.all().delete() + print(f" Удалено {count} записей HadisCategory") + + print("Удаление HadisSect...") + count = HadisSect.objects.count() + HadisSect.objects.all().delete() + print(f" Удалено {count} записей HadisSect") + + print("Удаление HadisStatus...") + count = HadisStatus.objects.count() + HadisStatus.objects.all().delete() + print(f" Удалено {count} записей HadisStatus") + + print("Удаление HadisTag...") + count = HadisTag.objects.count() + HadisTag.objects.all().delete() + print(f" Удалено {count} записей HadisTag") + + print("Удаление Transmitters...") + count = Transmitters.objects.count() + Transmitters.objects.all().delete() + print(f" Удалено {count} записей Transmitters") + + @transaction.atomic + def clear_library_data(self): + """Clear library data that was created by seeding""" + print("\n=== ОЧИСТКА ДАННЫХ БИБЛИОТЕКИ ===") + + # Only clear books that seem to be created by seeding script + # (based on Russian titles or specific patterns) + russian_book_titles = [ + 'Аль-Кафи', 'Сахих аль-Бухари', + 'Ман ля яхдуруху аль-факих', 'Сунан Абу Дауд' + ] + + books_to_delete = Book.objects.filter(title__in=russian_book_titles) + count = books_to_delete.count() + if count > 0: + books_to_delete.delete() + print(f" Удалено {count} книг с русскими названиями") + else: + print(" Книги с русскими названиями не найдены") + + # Clear library categories with Russian names + russian_categories = [ + 'Книги хадисов', 'Книги фикха', 'Книги толкования', + 'Книги нравственности', 'Исторические книги' + ] + + categories_to_delete = LibraryCategory.objects.filter(title__in=russian_categories) + count = categories_to_delete.count() + if count > 0: + categories_to_delete.delete() + print(f" Удалено {count} категорий библиотеки с русскими названиями") + else: + print(" Категории библиотеки с русскими названиями не найдены") + + # Clear book collections with Russian names + russian_collections = [ + 'Шиитские книги хадисов', 'Суннитские книги хадисов', + 'Сборник книг по фикху' + ] + + collections_to_delete = BookCollection.objects.filter(title__in=russian_collections) + count = collections_to_delete.count() + if count > 0: + collections_to_delete.delete() + print(f" Удалено {count} коллекций книг с русскими названиями") + else: + print(" Коллекции книг с русскими названиями не найдены") + + def run_cleanup(self, include_library=True): + """Main method to run cleanup""" + print("=" * 60) + print("ОЧИСТКА ДАННЫХ ХАДИСОВ") + print("=" * 60) + + try: + # Show current state + self.show_current_data() + + # Clear hadis data + self.clear_all_hadis_data() + + # Clear library data if requested + if include_library: + self.clear_library_data() + + # Show final state + print("\n=== ФИНАЛЬНОЕ СОСТОЯНИЕ ===") + self.show_current_data() + + print("\n✅ Очистка завершена успешно!") + + except Exception as e: + print(f"\n❌ Ошибка при очистке: {e}") + print("Откат транзакции...") + raise + + +def main(): + """Main function to run the cleanup script""" + import argparse + + parser = argparse.ArgumentParser(description='Clear hadis data from database') + parser.add_argument( + '--hadis-only', + action='store_true', + help='Clear only hadis data, keep library data' + ) + parser.add_argument( + '--force', + action='store_true', + help='Skip confirmation prompt' + ) + + args = parser.parse_args() + + include_library = not args.hadis_only + + if not args.force: + print("Это удалит все данные хадисов из базы данных.") + if include_library: + print("Также будут удалены связанные данные библиотеки.") + response = input("Вы уверены? (да/нет): ") + if response.lower() not in ['да', 'yes', 'y']: + print("Очистка отменена.") + return + + try: + cleaner = HadisDataCleaner() + cleaner.run_cleanup(include_library=include_library) + + except Exception as e: + print(f"\n❌ Очистка не удалась: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/optimize_hadis_transmitters.py b/scripts/optimize_hadis_transmitters.py new file mode 100755 index 0000000..5a74845 --- /dev/null +++ b/scripts/optimize_hadis_transmitters.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +Script to optimize Hadis Transmitter chains: +1. Limit each hadis to maximum 5 transmitter chain links +2. Remove excess transmitters if more than 5 +3. Ensure exactly one transmitter has is_gap=True (minimum 1, maximum 1) +""" + +import os +import sys +import django +from pathlib import Path +import random + +# Setup Django environment +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.append(str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +# Import models after Django setup +from apps.hadis.models import Hadis, HadisTransmitter +from django.db import transaction +from django.db.models import Count + + +class HadisTransmitterOptimizer: + """Optimizer for Hadis Transmitter chains""" + + def __init__(self): + self.max_transmitters = 5 + self.required_gaps = 1 # Exactly one gap required + + def optimize_all_hadis(self): + """Optimize transmitter chains for all hadis records""" + print("🔧 شروع بهینه‌سازی زنجیره راویان احادیث...") + print("=" * 60) + + # Get all hadis with transmitters + hadis_with_transmitters = Hadis.objects.annotate( + transmitter_count=Count('transmitters') + ).filter(transmitter_count__gt=0) + + total_hadis = hadis_with_transmitters.count() + + print(f"📊 تعداد کل احادیث با راوی: {total_hadis}") + print(f"⚙️ حداکثر راوی در هر حدیث: {self.max_transmitters}") + print(f"🔗 تعداد گپ مورد نیاز: دقیقاً {self.required_gaps} گپ") + print("-" * 60) + + optimized_count = 0 + removed_transmitters = 0 + + with transaction.atomic(): + for i, hadis in enumerate(hadis_with_transmitters, 1): + hadis_title = hadis.title[:30] if hadis.title else f"حدیث {hadis.number}" + print(f"\n[{i}/{total_hadis}] پردازش حدیث #{hadis.number}: {hadis_title}...") + + result = self.optimize_hadis_transmitters(hadis) + if result['optimized']: + optimized_count += 1 + removed_transmitters += result['removed_count'] + + # Progress indicator + if i % 25 == 0: + print(f"📈 پیشرفت: {i}/{total_hadis} ({(i/total_hadis)*100:.1f}%)") + + print("\n" + "=" * 60) + print("✅ بهینه‌سازی کامل شد!") + print(f"📊 آمار:") + print(f" - تعداد کل احادیث پردازش شده: {total_hadis}") + print(f" - تعداد احادیث بهینه‌سازی شده: {optimized_count}") + print(f" - تعداد راویان حذف شده: {removed_transmitters}") + print(f" - نرخ موفقیت: {(optimized_count/total_hadis)*100:.1f}%") + + return { + 'total_processed': total_hadis, + 'optimized_count': optimized_count, + 'removed_transmitters': removed_transmitters + } + + def optimize_hadis_transmitters(self, hadis): + """Optimize transmitter chain for a single hadis""" + # Get all transmitters for this hadis, ordered by order field + transmitters = list(hadis.transmitters.all().order_by('order')) + original_count = len(transmitters) + + print(f" 📋 تعداد راویان اصلی: {original_count}") + + needs_modification = False + + # 1. Check if more than 5 transmitters + if original_count > self.max_transmitters: + needs_modification = True + # Keep only first 5 transmitters (ordered by 'order' field) + transmitters_to_keep = transmitters[:self.max_transmitters] + transmitters_to_delete = transmitters[self.max_transmitters:] + + # Delete excess transmitters + removed_count = len(transmitters_to_delete) + for transmitter in transmitters_to_delete: + transmitter_name = transmitter.transmitter.full_name if transmitter.transmitter else 'گپ' + print(f" �️ حذف راوی: {transmitter_name} (ترتیب: {transmitter.order})") + transmitter.delete() + + transmitters = transmitters_to_keep + print(f" ✂️ تعداد راویان از {original_count} به {len(transmitters)} کاهش یافت") + else: + removed_count = 0 + + # 2. Ensure exactly one transmitter has is_gap=True + gap_transmitters = [t for t in transmitters if t.is_gap] + gap_count = len(gap_transmitters) + + if gap_count == 0: + # No gap transmitter, set one randomly + if transmitters: + random_transmitter = random.choice(transmitters) + random_transmitter.is_gap = True + random_transmitter.save() + needs_modification = True + print(f" 🔗 گپ به راوی ترتیب {random_transmitter.order} اضافه شد") + + elif gap_count > 1: + # Multiple gap transmitters, keep only one + transmitter_to_keep_gap = gap_transmitters[0] + for transmitter in gap_transmitters[1:]: + transmitter.is_gap = False + transmitter.save() + needs_modification = True + print(f" � گپ از {gap_count-1} راوی حذف شد، فقط راوی ترتیب {transmitter_to_keep_gap.order} گپ باقی ماند") + + # Reorder transmitters to ensure proper sequence + if needs_modification: + self._reorder_transmitters(transmitters) + + final_gap_count = sum(1 for t in transmitters if t.is_gap) + + if needs_modification: + print(f" ✅ بهینه‌سازی شد: {original_count} -> {len(transmitters)} راوی") + print(f" 🔗 تعداد گپ: {final_gap_count}") + else: + print(f" ✅ قبلاً بهینه بود (گپ: {final_gap_count})") + + return {'optimized': needs_modification, 'removed_count': removed_count} + + def _reorder_transmitters(self, transmitters): + """Reorder transmitters with proper order values""" + for i, transmitter in enumerate(transmitters, 1): + transmitter.order = i + transmitter.save() + + + + def get_statistics(self): + """Get current statistics about transmitter chains""" + print("\n📊 آمار فعلی زنجیره راویان:") + print("-" * 50) + + # Total hadis with transmitters + hadis_with_transmitters = Hadis.objects.annotate( + transmitter_count=Count('transmitters') + ).filter(transmitter_count__gt=0) + + total_hadis = hadis_with_transmitters.count() + + # Transmitter count distribution + chain_lengths = {} + gap_distributions = {} + + for hadis in hadis_with_transmitters: + transmitter_count = hadis.transmitter_count + gap_count = hadis.transmitters.filter(is_gap=True).count() + + chain_lengths[transmitter_count] = chain_lengths.get(transmitter_count, 0) + 1 + gap_distributions[gap_count] = gap_distributions.get(gap_count, 0) + 1 + + print(f"تعداد کل احادیث با راوی: {total_hadis}") + print("\nتوزیع طول زنجیره:") + for length in sorted(chain_lengths.keys()): + count = chain_lengths[length] + percentage = (count / total_hadis) * 100 if total_hadis > 0 else 0 + print(f" {length} راوی: {count} حدیث ({percentage:.1f}%)") + + print("\nتوزیع گپ:") + for gaps in sorted(gap_distributions.keys()): + count = gap_distributions[gaps] + percentage = (count / total_hadis) * 100 if total_hadis > 0 else 0 + print(f" {gaps} گپ: {count} حدیث ({percentage:.1f}%)") + + # Identify problematic hadis + problematic = 0 + for hadis in hadis_with_transmitters: + transmitter_count = hadis.transmitter_count + gap_count = hadis.transmitters.filter(is_gap=True).count() + + if transmitter_count > self.max_transmitters or gap_count != self.required_gaps: + problematic += 1 + + print(f"\nاحادیث مشکل‌دار (نیاز به بهینه‌سازی): {problematic}") + if total_hadis > 0: + print(f"درصد نیاز به بهینه‌سازی: {(problematic/total_hadis)*100:.1f}%") + + +def main(): + """Main function""" + import argparse + + parser = argparse.ArgumentParser(description='بهینه‌سازی زنجیره راویان احادیث') + parser.add_argument('--stats-only', action='store_true', help='فقط نمایش آمار، بدون بهینه‌سازی') + parser.add_argument('--dry-run', action='store_true', help='نمایش تغییرات بدون اعمال آن‌ها') + + args = parser.parse_args() + + optimizer = HadisTransmitterOptimizer() + + if args.stats_only: + optimizer.get_statistics() + else: + # Show current statistics + optimizer.get_statistics() + + if not args.dry_run: + print("\n" + "="*60) + confirm = input("🚨 این عملیات زنجیره راویان را تغییر خواهد داد. ادامه می‌دهید؟ (yes/no): ").strip().lower() + + if confirm == 'yes': + optimizer.optimize_all_hadis() + print(f"\n🎉 بهینه‌سازی با موفقیت کامل شد!") + + # Show final statistics + print("\n" + "="*60) + print("📊 آمار نهایی:") + optimizer.get_statistics() + else: + print("❌ عملیات لغو شد.") + else: + print("\n🔍 حالت آزمایشی - هیچ تغییری اعمال نخواهد شد") + print("برای اجرای واقعی، بدون --dry-run اجرا کنید") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n❌ عملیات توسط کاربر لغو شد.") + sys.exit(1) + except Exception as e: + print(f"\n💥 خطا: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/scripts/seed_books.py b/scripts/seed_books.py new file mode 100644 index 0000000..a321a3c --- /dev/null +++ b/scripts/seed_books.py @@ -0,0 +1,276 @@ +""" +Django management command to seed mock data for hadith book references. +Place this file in: yourapp/management/commands/seed_books.py + +Usage: python manage.py seed_books +""" + +import os +from pathlib import Path +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from django.utils.text import slugify +from apps.hadis.models.reference import ( + BookReference, + BookReferenceImage, + BookAuthor, + BookAttribute +) + + +class Command(BaseCommand): + help = 'Seed the database with mock hadith book reference data' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing data before seeding', + ) + + def handle(self, *args, **options): + if options['clear']: + self.stdout.write(self.style.WARNING('Clearing existing data...')) + BookAttribute.objects.all().delete() + BookReferenceImage.objects.all().delete() + BookAuthor.objects.all().delete() + BookReference.objects.all().delete() + self.stdout.write(self.style.SUCCESS('Data cleared successfully!')) + + # Create authors first + authors_data = [ + {"name": "Imam Muhammad al-Bukhari"}, + {"name": "Imam Muslim ibn al-Hajjaj"}, + {"name": "Imam Abu Dawood as-Sijistani"}, + {"name": "Imam At-Tirmidhi"}, + {"name": "Imam Ibn Majah"}, + {"name": "Imam Ahmad ibn Hanbal"}, + {"name": "Imam Al-Hakim"}, + {"name": "Imam Ad-Daraqutni"}, + ] + + authors = {} + for author_data in authors_data: + author, created = BookAuthor.objects.get_or_create( + name=author_data['name'] + ) + authors[author_data['name']] = author + if created: + self.stdout.write(self.style.SUCCESS(f'Created author: {author.name}')) + + # Create book references + books_data = [ + { + "title": "Sahih al-Bukhari", + "description": "The most authentic collection of hadith, compiled by Imam Muhammad al-Bukhari. Contains 7,563 ahadith.", + "language": "Arabic", + "isbn": "978-1-86043-009-6", + "volume": "9 volumes", + "year_of_publication": "1870", + "number_page": 1200, + "publisher": "Dar al-Kutub al-Ilmiyah", + "rate": 5.00, + "authors": ["Imam Muhammad al-Bukhari"], + "image_order": 1, + "attributes": { + "Collection Type": "Hadith Compilation", + "Number of Hadith": "7,563", + "Classification": "6 Books", + "Authenticity Grade": "Sahih (Authentic)", + "Compilation Period": "16 years", + } + }, + { + "title": "Sahih Muslim", + "description": "Second most authentic hadith collection compiled by Imam Muslim ibn al-Hajjaj. Contains 9,200 traditions.", + "language": "Arabic", + "isbn": "978-1-86043-010-2", + "volume": "5 volumes", + "year_of_publication": "1875", + "number_page": 1500, + "publisher": "Dar Ihya at-Turath al-Arabi", + "rate": 4.95, + "authors": ["Imam Muslim ibn al-Hajjaj"], + "image_order": 2, + "attributes": { + "Collection Type": "Hadith Compilation", + "Number of Hadith": "9,200", + "Classification": "43 Books", + "Authenticity Grade": "Sahih (Authentic)", + "Unique Hadith": "Approximately 4,000", + } + }, + { + "title": "Sunan Abu Dawood", + "description": "A comprehensive collection of hadith containing jurisprudential material, compiled by Imam Abu Dawood as-Sijistani.", + "language": "Arabic", + "isbn": "978-1-86043-011-9", + "volume": "4 volumes", + "year_of_publication": "1880", + "number_page": 1400, + "publisher": "Islamic Digital Library", + "rate": 4.80, + "authors": ["Imam Abu Dawood as-Sijistani"], + "image_order": 3, + "attributes": { + "Collection Type": "Sunan (Practice)", + "Number of Hadith": "5,274", + "Focus": "Jurisprudential Traditions", + "Number of Books": "43", + "Authenticity Grade": "Hasan to Sahih", + } + }, + { + "title": "Jami' at-Tirmidhi", + "description": "A major collection of hadith compiled by Imam At-Tirmidhi with his commentary and grading of narrations.", + "language": "Arabic", + "isbn": "978-1-86043-012-6", + "volume": "5 volumes", + "year_of_publication": "1892", + "number_page": 1350, + "publisher": "Dar ar-Risalah al-Alamiyah", + "rate": 4.85, + "authors": ["Imam At-Tirmidhi"], + "image_order": 4, + "attributes": { + "Collection Type": "Jami (Comprehensive)", + "Number of Hadith": "3,956", + "Notable Feature": "Grades each hadith", + "Categories": "63 Chapters", + "Authenticity Grade": "Various Grades", + } + }, + { + "title": "Sunan Ibn Majah", + "description": "A collection of hadith compiled by Imam Ibn Majah, one of the Six Canonical Hadith Collections.", + "language": "Arabic", + "isbn": "978-1-86043-013-3", + "volume": "2 volumes", + "year_of_publication": "1888", + "number_page": 900, + "publisher": "Dar Ihya al-Kutub al-Arabiyah", + "rate": 4.75, + "authors": ["Imam Ibn Majah"], + "image_order": 5, + "attributes": { + "Collection Type": "Sunan (Practice)", + "Number of Hadith": "4,341", + "Number of Books": "32", + "Notable Content": "Includes rare narrations", + "Authenticity Grade": "Mixed - requires verification", + } + }, + ] + + books = {} + for book_data in books_data: + # Extract author names + author_names = book_data.pop('authors', []) + image_order = book_data.pop('image_order', 1) + attributes_dict = book_data.pop('attributes', {}) + + # Create or get the book + book, created = BookReference.objects.get_or_create( + title=book_data['title'], + defaults=book_data + ) + + if created: + self.stdout.write(self.style.SUCCESS(f'Created book: {book.title}')) + else: + # Update existing book + for key, value in book_data.items(): + setattr(book, key, value) + book.save() + self.stdout.write(self.style.WARNING(f'Updated book: {book.title}')) + + books[book.title] = book + + # Add authors to book + for author_name in author_names: + author = authors.get(author_name) + if author: + book.authors.add(author) + + # Add book image + image_path = self._get_image_path(image_order) + if image_path and os.path.exists(image_path): + # Check if image already exists for this book + if not book.images.exists(): + with open(image_path, 'rb') as img_file: + image_name = f'book{image_order}.png' + book_image = BookReferenceImage.objects.create( + book_reference=book, + order=1, + description=f"Cover image for {book.title}" + ) + book_image.image.save( + image_name, + ContentFile(img_file.read()), + save=True + ) + self.stdout.write( + self.style.SUCCESS(f'Added image to: {book.title}') + ) + else: + self.stdout.write( + self.style.WARNING( + f'Image not found at {image_path} for {book.title}' + ) + ) + + # Add attributes + for attr_title, attr_value in attributes_dict.items(): + attribute, created = BookAttribute.objects.get_or_create( + book_reference=book, + title=attr_title, + defaults={'value': attr_value} + ) + if created: + self.stdout.write( + self.style.SUCCESS( + f'Added attribute: {attr_title} to {book.title}' + ) + ) + + self.stdout.write( + self.style.SUCCESS( + f'\n✓ Successfully seeded {len(books)} books with all relations!' + ) + ) + self._print_summary() + + def _get_image_path(self, book_number): + """ + Find the image file for the given book number. + Checks multiple possible locations. + """ + possible_paths = [ + Path('seeds/images') / f'book{book_number}.png', + Path('seed_data/images') / f'book{book_number}.png', + Path('static/images') / f'book{book_number}.png', + Path('.') / 'seeds' / 'images' / f'book{book_number}.png', + ] + + for path in possible_paths: + if path.exists(): + return path + + return None + + def _print_summary(self): + """Print a summary of created data""" + self.stdout.write("\n" + "="*60) + self.stdout.write(self.style.SUCCESS("DATABASE SUMMARY")) + self.stdout.write("="*60) + + books_count = BookReference.objects.count() + authors_count = BookAuthor.objects.count() + images_count = BookReferenceImage.objects.count() + attributes_count = BookAttribute.objects.count() + + self.stdout.write(f"📚 Total Books: {books_count}") + self.stdout.write(f"✍️ Total Authors: {authors_count}") + self.stdout.write(f"🖼️ Total Images: {images_count}") + self.stdout.write(f"🏷️ Total Attributes: {attributes_count}") + self.stdout.write("="*60 + "\n") \ No newline at end of file diff --git a/scripts/seed_hadis_data.py b/scripts/seed_hadis_data.py new file mode 100644 index 0000000..21a6867 --- /dev/null +++ b/scripts/seed_hadis_data.py @@ -0,0 +1,1379 @@ +#!/usr/bin/env python3 +""" +Comprehensive data seeding script for Hadis app models. +This script creates realistic sample records for all Hadis app models +while maintaining proper relationships and business domain logic. +""" + +import os +import sys +import django +from pathlib import Path +from django.core.files import File +from django.core.files.base import ContentFile +from django.db import transaction, models +import random + +# Setup Django environment +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.append(str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') +django.setup() + +# Import models after Django setup +from apps.hadis.models import ( + HadisSect, HadisCategory, HadisStatus, HadisTag, Hadis, + Transmitters, HadisTransmitter, HadisReference, ReferenceImage +) +from apps.library.models import Book, Category as LibraryCategory, BookCollection + + +class HadisDataSeeder: + """Main seeder class for Hadis app data""" + + def __init__(self): + self.script_dir = Path(__file__).parent + self.seed_images_dir = self.script_dir / 'seed_images' + self.xmind_file_path = self.script_dir / 'test.xmind' + + # Verify required files exist + if not self.seed_images_dir.exists(): + raise FileNotFoundError(f"Seed images directory not found: {self.seed_images_dir}") + # XMind file is optional + # if not self.xmind_file_path.exists(): + # raise FileNotFoundError(f"XMind file not found: {self.xmind_file_path}") + + # Get available images + self.image_files = list(self.seed_images_dir.glob('*.png')) + if not self.image_files: + raise FileNotFoundError("No PNG images found in seed_images directory") + + print(f"Found {len(self.image_files)} seed images") + print(f"XMind file: {self.xmind_file_path}") + + def clear_existing_data(self): + """Clear existing hadis data (optional - for clean seeding)""" + print("Clearing existing hadis data...") + + # Clear in reverse dependency order + ReferenceImage.objects.all().delete() + HadisReference.objects.all().delete() + HadisTransmitter.objects.all().delete() + Hadis.objects.all().delete() + HadisCategory.objects.all().delete() + HadisSect.objects.all().delete() + HadisStatus.objects.all().delete() + HadisTag.objects.all().delete() + Transmitters.objects.all().delete() + + print("Existing data cleared.") + + def seed_hadis_statuses(self): + """Create HadisStatus records""" + print("Creating Hadis Statuses...") + + statuses_data = [ + {'title': 'Достоверный', 'color': 'green', 'order': 1}, + {'title': 'Хороший', 'color': 'blue', 'order': 2}, + {'title': 'Слабый', 'color': 'yellow', 'order': 3}, + {'title': 'Выдуманный', 'color': 'red', 'order': 4}, + {'title': 'Прерванный', 'color': 'orange', 'order': 5}, + {'title': 'Разорванный', 'color': 'purple', 'order': 6}, + {'title': 'Неизвестный', 'color': 'gray', 'order': 7}, + ] + + statuses = [] + for data in statuses_data: + status, created = HadisStatus.objects.get_or_create( + title=data['title'], + defaults=data + ) + statuses.append(status) + if created: + print(f" Created status: {status.title}") + + return statuses + + def seed_hadis_tags(self): + """Create HadisTag records""" + print("Creating Hadis Tags...") + + tags_data = [ + 'Поклонение', 'Молитва', 'Пост', 'Хадж', 'Закят', 'Хумс', + 'Нравственность', 'Терпение', 'Благодарность', 'Упование', 'Богобоязненность', 'Справедливость', + 'Фикх', 'Предписания', 'Дозволенное', 'Запретное', 'Желательное', 'Нежелательное', + 'Толкование', 'Коран', 'Аяты', 'Сура', 'Чтение', + 'Имамат', 'Власть', 'Непорочные', 'Семья Пророка', + 'Мольба', 'Поминание', 'Прощение', 'Восхваление', 'Единобожие' + ] + + tags = [] + for tag_title in tags_data: + tag, created = HadisTag.objects.get_or_create( + title=tag_title, + defaults={'status': True} + ) + tags.append(tag) + if created: + print(f" Created tag: {tag.title}") + + return tags + + def seed_hadis_sects(self): + """Create HadisSect records""" + print("Creating Hadis Sects...") + + sects_data = [ + {'sect_type': 'shia', 'title': 'Шииты-двунадесятники', 'is_active': True, 'order': 1}, + {'sect_type': 'sunni', 'title': 'Сунниты', 'is_active': True, 'order': 2}, + ] + + sects = [] + for data in sects_data: + sect, created = HadisSect.objects.get_or_create( + sect_type=data['sect_type'], + defaults=data + ) + sects.append(sect) + if created: + print(f" Created sect: {sect.title}") + + return sects + + def assign_xmind_file(self, category): + """Assign XMind file to category""" + try: + with open(self.xmind_file_path, 'rb') as f: + file_content = f.read() + + # Create unique filename for each category + filename = f"category_{category.id}_{category.title[:20]}.xmind" + category.xmind_file.save( + filename, + ContentFile(file_content), + save=True + ) + return True + except Exception as e: + print(f" Warning: Could not assign XMind file to {category.title}: {e}") + return False + + def seed_hadis_categories(self, sects): + """Create HadisCategory records with hierarchical structure - optimized batch creation""" + print("Creating Hadis Categories...") + + categories = [] + categories_to_create = [] + categories_to_update = [] + + for sect in sects: + print(f" Creating categories for {sect.title}...") + + # Quran categories + quran_categories_data = [ + {'title': 'Толкование Корана', 'order': 1}, + {'title': 'Аяты предписаний', 'order': 2}, + {'title': 'Рассказы Корана', 'order': 3}, + {'title': 'Достоинства сур', 'order': 4}, + {'title': 'Чудеса Корана', 'order': 5}, + {'title': 'Коранические науки', 'order': 6}, + ] + + # First, get existing categories to avoid duplicates + existing_quran_categories = { + cat.title: cat for cat in HadisCategory.objects.filter( + sect=sect, source_type='quran', parent=None + ) + } + + # Process main Quran categories + for cat_data in quran_categories_data: + if cat_data['title'] in existing_quran_categories: + category = existing_quran_categories[cat_data['title']] + if category.order != cat_data['order']: + category.order = cat_data['order'] + categories_to_update.append(category) + print(f" Will update Quran category: {category.title}") + else: + print(f" Quran category exists: {category.title}") + else: + category = HadisCategory( + sect=sect, + source_type='quran', + title=cat_data['title'], + order=cat_data['order'] + ) + categories_to_create.append(category) + print(f" Will create Quran category: {cat_data['title']}") + + categories.append(category) + + # Batch create new categories + if categories_to_create: + HadisCategory.objects.bulk_create(categories_to_create, ignore_conflicts=True) + print(f" Batch created {len(categories_to_create)} Quran categories") + categories_to_create = [] + + # Batch update existing categories + if categories_to_update: + HadisCategory.objects.bulk_update(categories_to_update, ['order']) + print(f" Batch updated {len(categories_to_update)} Quran categories") + categories_to_update = [] + + # Now handle child categories + parent_categories = HadisCategory.objects.filter( + sect=sect, source_type='quran', parent=None + ) + + for parent_category in parent_categories: + child_categories_data = [] + + if parent_category.title == 'Толкование Корана': + child_categories_data = [ + {'title': 'Толкование суры Аль-Фатиха', 'order': 1}, + {'title': 'Толкование суры Аль-Бакара', 'order': 2}, + {'title': 'Толкование суры Аль Имран', 'order': 3}, + {'title': 'Толкование суры Ан-Ниса', 'order': 4}, + {'title': 'Толкование суры Аль-Маида', 'order': 5}, + ] + elif parent_category.title == 'Аяты предписаний': + child_categories_data = [ + {'title': 'Аяты о молитве', 'order': 1}, + {'title': 'Аяты о посте', 'order': 2}, + {'title': 'Аяты о закяте', 'order': 3}, + {'title': 'Аяты о хадже', 'order': 4}, + ] + elif parent_category.title == 'Рассказы Корана': + child_categories_data = [ + {'title': 'История пророков', 'order': 1}, + {'title': 'Рассказы о праведниках', 'order': 2}, + {'title': 'Уроки из истории', 'order': 3}, + ] + elif parent_category.title == 'Достоинства сур': + child_categories_data = [ + {'title': 'Достоинства суры Аль-Фатиха', 'order': 1}, + {'title': 'Достоинства суры Аль-Бакара', 'order': 2}, + {'title': 'Достоинства суры Йа-Син', 'order': 3}, + {'title': 'Достоинства суры Аль-Мульк', 'order': 4}, + ] + + if child_categories_data: + # Get existing child categories + existing_children = { + cat.title: cat for cat in HadisCategory.objects.filter( + parent=parent_category, sect=sect, source_type='quran' + ) + } + + # Process child categories + for child_data in child_categories_data: + if child_data['title'] in existing_children: + child_category = existing_children[child_data['title']] + if child_category.order != child_data['order']: + child_category.order = child_data['order'] + categories_to_update.append(child_category) + print(f" Will update child category: {child_category.title}") + else: + print(f" Child category exists: {child_category.title}") + else: + child_category = HadisCategory( + parent=parent_category, + sect=sect, + source_type='quran', + title=child_data['title'], + order=child_data['order'] + ) + categories_to_create.append(child_category) + print(f" Will create child category: {child_data['title']}") + + categories.append(child_category) + + # Batch operations for child categories + if categories_to_create: + HadisCategory.objects.bulk_create(categories_to_create, ignore_conflicts=True) + print(f" Batch created {len(categories_to_create)} child categories") + categories_to_create = [] + + if categories_to_update: + HadisCategory.objects.bulk_update(categories_to_update, ['order']) + print(f" Batch updated {len(categories_to_update)} child categories") + categories_to_update = [] + + # Assign XMind file to some categories (after creation) + if random.choice([True, False]) and not parent_category.xmind_file: + self.assign_xmind_file(parent_category) + + # Hadith categories - optimized batch processing + hadith_categories_data = [ + {'title': 'Книга очищения', 'order': 1}, + {'title': 'Книга молитвы', 'order': 2}, + {'title': 'Книга поста', 'order': 3}, + {'title': 'Книга хаджа', 'order': 4}, + {'title': 'Книга закята', 'order': 5}, + {'title': 'Книга брака', 'order': 6}, + {'title': 'Книга нравственности', 'order': 7}, + {'title': 'Книга торговли', 'order': 8}, + {'title': 'Книга джихада', 'order': 9}, + {'title': 'Книга судопроизводства', 'order': 10}, + ] + + # Get existing hadith categories + existing_hadith_categories = { + cat.title: cat for cat in HadisCategory.objects.filter( + sect=sect, source_type='hadith', parent=None + ) + } + + categories_to_create = [] + categories_to_update = [] + + # Process main Hadith categories + for cat_data in hadith_categories_data: + if cat_data['title'] in existing_hadith_categories: + category = existing_hadith_categories[cat_data['title']] + if category.order != cat_data['order']: + category.order = cat_data['order'] + categories_to_update.append(category) + print(f" Will update Hadith category: {category.title}") + else: + print(f" Hadith category exists: {category.title}") + else: + category = HadisCategory( + sect=sect, + source_type='hadith', + title=cat_data['title'], + order=cat_data['order'] + ) + categories_to_create.append(category) + print(f" Will create Hadith category: {cat_data['title']}") + + categories.append(category) + + # Batch create new hadith categories + if categories_to_create: + HadisCategory.objects.bulk_create(categories_to_create, ignore_conflicts=True) + print(f" Batch created {len(categories_to_create)} Hadith categories") + + # Batch update existing hadith categories + if categories_to_update: + HadisCategory.objects.bulk_update(categories_to_update, ['order']) + print(f" Batch updated {len(categories_to_update)} Hadith categories") + + # Now handle hadith child categories + parent_categories = HadisCategory.objects.filter( + sect=sect, source_type='hadith', parent=None + ) + + for parent_category in parent_categories: + child_categories_data = [] + + if parent_category.title == 'Книга очищения': + child_categories_data = [ + {'title': 'Омовение', 'order': 1}, + {'title': 'Полное омовение', 'order': 2}, + {'title': 'Сухое омовение', 'order': 3}, + {'title': 'Нечистоты', 'order': 4}, + ] + elif parent_category.title == 'Книга молитвы': + child_categories_data = [ + {'title': 'Времена молитвы', 'order': 1}, + {'title': 'Кибла', 'order': 2}, + {'title': 'Азан и икама', 'order': 3}, + {'title': 'Коллективная молитва', 'order': 4}, + {'title': 'Пятничная молитва', 'order': 5}, + ] + elif parent_category.title == 'Книга поста': + child_categories_data = [ + {'title': 'Пост в Рамадан', 'order': 1}, + {'title': 'Добровольный пост', 'order': 2}, + {'title': 'Нарушители поста', 'order': 3}, + {'title': 'Ночь предопределения', 'order': 4}, + ] + elif parent_category.title == 'Книга хаджа': + child_categories_data = [ + {'title': 'Обряды хаджа', 'order': 1}, + {'title': 'Умра', 'order': 2}, + {'title': 'Запреты ихрама', 'order': 3}, + ] + elif parent_category.title == 'Книга нравственности': + child_categories_data = [ + {'title': 'Терпение и благодарность', 'order': 1}, + {'title': 'Справедливость и честность', 'order': 2}, + {'title': 'Знание и мудрость', 'order': 3}, + {'title': 'Дружба и братство', 'order': 4}, + ] + + if child_categories_data: + # Get existing child categories + existing_children = { + cat.title: cat for cat in HadisCategory.objects.filter( + parent=parent_category, sect=sect, source_type='hadith' + ) + } + + categories_to_create = [] + categories_to_update = [] + + # Process child categories + for child_data in child_categories_data: + if child_data['title'] in existing_children: + child_category = existing_children[child_data['title']] + if child_category.order != child_data['order']: + child_category.order = child_data['order'] + categories_to_update.append(child_category) + print(f" Will update child category: {child_category.title}") + else: + print(f" Child category exists: {child_category.title}") + else: + child_category = HadisCategory( + parent=parent_category, + sect=sect, + source_type='hadith', + title=child_data['title'], + order=child_data['order'] + ) + categories_to_create.append(child_category) + print(f" Will create child category: {child_data['title']}") + + categories.append(child_category) + + # Batch operations for child categories + if categories_to_create: + HadisCategory.objects.bulk_create(categories_to_create, ignore_conflicts=True) + print(f" Batch created {len(categories_to_create)} child categories") + + if categories_to_update: + HadisCategory.objects.bulk_update(categories_to_update, ['order']) + print(f" Batch updated {len(categories_to_update)} child categories") + + # Assign XMind file to some categories (after creation) + if random.choice([True, False]) and not parent_category.xmind_file: + self.assign_xmind_file(parent_category) + + return categories + + def seed_library_data(self): + """Create library data (books, categories, collections) for references""" + print("Creating Library data...") + + # Create library categories + lib_categories_data = [ + 'Книги хадисов', 'Книги фикха', 'Книги толкования', 'Книги нравственности', 'Исторические книги' + ] + + lib_categories = [] + for cat_title in lib_categories_data: + category, created = LibraryCategory.objects.get_or_create( + title=cat_title, + defaults={'status': True} + ) + lib_categories.append(category) + if created: + print(f" Created library category: {category.title}") + + # Create book collections + collections_data = [ + {'title': 'Шиитские книги хадисов', 'display_position': 'pinned'}, + {'title': 'Суннитские книги хадисов', 'display_position': 'middle'}, + {'title': 'Сборник книг по фикху', 'display_position': 'middle'}, + ] + + collections = [] + for coll_data in collections_data: + collection, created = BookCollection.objects.get_or_create( + title=coll_data['title'], + defaults={ + 'summary': f'Коллекция {coll_data["title"]}', + 'display_position': coll_data['display_position'], + 'status': True, + 'order': len(collections) + 1 + } + ) + collections.append(collection) + if created: + print(f" Created collection: {collection.title}") + + # Create books with cover images + books_data = [ + { + 'title': 'Аль-Кафи', + 'summary_title': 'Книга Аль-Кафи шейха Кулейни', + 'summary': 'Одна из важнейших книг хадисов шиитов', + 'description': 'Книга Аль-Кафи, написанная Мухаммадом ибн Якубом Кулейни, является одной из четырех достоверных книг хадисов шиитов.', + 'publisher': 'Дар аль-Кутуб аль-Исламийя', + 'year_of_publication': '1407', + 'isbn': '978-964-372-001-1', + 'pages_count': '2847', + 'file_type': 'pdf' + }, + { + 'title': 'Сахих аль-Бухари', + 'summary_title': 'Сахих аль-Бухари имама Бухари', + 'summary': 'Самая достоверная книга хадисов суннитов', + 'description': 'Сахих аль-Бухари, написанный Мухаммадом ибн Исмаилом Бухари, является самой достоверной книгой хадисов у суннитов.', + 'publisher': 'Дар Тук ан-Наджа', + 'year_of_publication': '1422', + 'isbn': '978-964-372-002-2', + 'pages_count': '1896', + 'file_type': 'pdf' + }, + { + 'title': 'Ман ля яхдуруху аль-факих', + 'summary_title': 'Ман ля яхдуруху аль-факих шейха Садука', + 'summary': 'Важная книга по фикху и хадисам шиитов', + 'description': 'Книга Ман ля яхдуруху аль-факих, написанная шейхом Садуком, является одной из четырех книг шиитов.', + 'publisher': 'Муассаса ан-Нашр аль-Ислами', + 'year_of_publication': '1413', + 'isbn': '978-964-372-003-3', + 'pages_count': '1524', + 'file_type': 'pdf' + }, + { + 'title': 'Сунан Абу Дауд', + 'summary_title': 'Сунан Абу Дауд имама Абу Дауда', + 'summary': 'Одна из шести книг суннитов', + 'description': 'Сунан Абу Дауд, написанная Сулейманом ибн Ашасом Сиджистани, является одной из шести книг суннитов.', + 'publisher': 'Аль-Мактаба аль-Асрийя', + 'year_of_publication': '1430', + 'isbn': '978-964-372-004-4', + 'pages_count': '1342', + 'file_type': 'pdf' + }, + ] + + books = [] + for i, book_data in enumerate(books_data): + # Get random image for book cover + image_file = random.choice(self.image_files) + + book, created = Book.objects.get_or_create( + title=book_data['title'], + defaults=book_data + ) + + if created: + # Assign cover image + try: + with open(image_file, 'rb') as f: + book.thumbnail.save( + f"book_cover_{book.id}.png", + File(f), + save=True + ) + print(f" Created book: {book.title} with cover image") + except Exception as e: + print(f" Created book: {book.title} (no cover image: {e})") + + # Assign to categories and collections + if lib_categories: + book.categories.add(random.choice(lib_categories)) + if collections: + book.collections.add(random.choice(collections)) + + books.append(book) + + return books, lib_categories, collections + + def seed_transmitters(self): + """Create Transmitters records""" + print("Creating Transmitters...") + + transmitters_data = [ + { + 'full_name': 'Мухаммад ибн Якуб Кулейни', + 'birth_year_hijri': 250, + 'death_year_hijri': 329, + 'description': 'Шейх Кулейни, автор книги Аль-Кафи и один из великих мухаддисов шиитов' + }, + { + 'full_name': 'Мухаммад ибн Али ибн Бабавейх (Шейх Садук)', + 'birth_year_hijri': 306, + 'death_year_hijri': 381, + 'description': 'Шейх Садук, автор книги Ман ля яхдуруху аль-факих' + }, + { + 'full_name': 'Мухаммад ибн аль-Хасан ат-Туси', + 'birth_year_hijri': 385, + 'death_year_hijri': 460, + 'description': 'Шейх Туси, автор книг Тахзиб аль-Ахкам и аль-Истибсар' + }, + { + 'full_name': 'Мухаммад ибн Исмаил аль-Бухари', + 'birth_year_hijri': 194, + 'death_year_hijri': 256, + 'description': 'Имам Бухари, автор Сахих аль-Бухари' + }, + { + 'full_name': 'Муслим ибн аль-Хаджжадж ан-Нишапури', + 'birth_year_hijri': 206, + 'death_year_hijri': 261, + 'description': 'Имам Муслим, автор Сахих Муслим' + }, + { + 'full_name': 'Абу Дауд ас-Сиджистани', + 'birth_year_hijri': 202, + 'death_year_hijri': 275, + 'description': 'Имам Абу Дауд, автор Сунан Абу Дауд' + }, + { + 'full_name': 'Джафар ибн Мухаммад ас-Садик', + 'birth_year_hijri': 83, + 'death_year_hijri': 148, + 'description': 'Имам Джафар Садик (мир ему), шестой имам шиитов' + }, + { + 'full_name': 'Мухаммад ибн Али аль-Бакир', + 'birth_year_hijri': 57, + 'death_year_hijri': 114, + 'description': 'Имам Мухаммад Бакир (мир ему), пятый имам шиитов' + }, + { + 'full_name': 'Али ибн аль-Хусейн ас-Саджжад', + 'birth_year_hijri': 38, + 'death_year_hijri': 95, + 'description': 'Имам Али ибн аль-Хусейн (мир ему), четвертый имам шиитов' + }, + { + 'full_name': 'Мухаммад ибн Муслим', + 'birth_year_hijri': 70, + 'death_year_hijri': 150, + 'description': 'Мухаммад ибн Муслим, из сподвижников имама Бакира и имама Садика (мир им)' + }, + ] + + transmitters = [] + for trans_data in transmitters_data: + transmitter, created = Transmitters.objects.get_or_create( + full_name=trans_data['full_name'], + defaults=trans_data + ) + transmitters.append(transmitter) + if created: + print(f" Created transmitter: {transmitter.full_name}") + + return transmitters + + def seed_hadis_records(self, categories, statuses, tags, transmitters, books): + """Create Hadis records with proper relationships - optimized batch creation""" + print("Creating Hadis records...") + + # Get only leaf categories (categories without children) - optimized query + leaf_categories = HadisCategory.objects.filter( + id__in=[cat.id for cat in categories] + ).annotate( + children_count=models.Count('children') + ).filter(children_count=0) + + print(f"Found {len(leaf_categories)} leaf categories for hadis creation") + + # Comprehensive hadis samples with longer texts + hadis_samples = { + 'prayer': [ + { + 'title': 'Достоинство молитвы и ее место в религии', + 'text': '''قال رسول الله صلى الله عليه وآله: الصلاة عمود الدين، إن قبلت قبل ما سواها، وإن ردت رد ما سواها. وهي أول ما يحاسب عليه العبد يوم القيامة، فإن صلحت صلح سائر عمله، وإن فسدت فسد سائر عمله. + +والصلاة معراج المؤمن، وهي قربان كل تقي، وهي حب الله تعالى. من أحبها وأقامها في أوقاتها وحافظ على حدودها رفعه الله إلى درجة الأبرار. ومن استخف بها وضيعها وتركها فقد استخف بدين الله، ولا نصيب له في الإسلام. + +إن الله تعالى فرض خمس صلوات في اليوم والليلة، وجعل لكل صلاة وقتاً معلوماً، فمن صلاها في وقتها وأتم ركوعها وسجودها وخشوعها، كانت له نوراً وبرهاناً ونجاة يوم القيامة.''', + 'translation': [ + {'language_code': 'ru', 'title': '''Сказал Посланник Аллаха (да благословит Аллах его и его семейство): Молитва - столп религии, если она принята, то принято и остальное, а если отвергнута, то отвергнуто и остальное. Это первое, за что будет спрошен раб в День Воскресения, и если она будет правильной, то правильными будут и остальные его дела, а если испорчена, то испорчены и остальные его дела. + +Молитва - это вознесение верующего, она - приношение каждого богобоязненного, она - любовь Всевышнего Аллаха. Кто полюбил ее и совершал ее в установленные времена и соблюдал ее границы, того Аллах возвысит до степени праведников. А кто пренебрег ею, потерял ее и оставил, тот пренебрег религией Аллаха, и нет ему доли в Исламе. + +Поистине, Всевышний Аллах предписал пять молитв в сутки и установил для каждой молитвы определенное время. Кто совершал их в свое время и завершал их поклоны, земные поклоны и смирение, для того они станут светом, доказательством и спасением в День Воскресения.'''}, + {'language_code': 'fa', 'title': '''رسول خدا فرمود: نماز ستون دین است، اگر پذیرفته شود غیر آن نیز پذیرفته می‌شود و اگر رد شود غیر آن نیز رد می‌شود. و این اولین چیزی است که بنده در روز قیامت از آن بازخواست می‌شود، پس اگر درست باشد تمام اعمالش درست است و اگر فاسد باشد تمام اعمالش فاسد است. + +نماز معراج مؤمن است و قربانی هر پرهیزکار و محبت خداوند متعال است. هر کس آن را دوست بدارد و در اوقاتش برپا دارد و حدودش را نگه دارد، خداوند او را به درجه نیکان بالا می‌برد. و هر کس آن را سبک بشمارد و ضایع کند و ترک کند، دین خدا را سبک شمرده و بهره‌ای در اسلام ندارد. + +خداوند متعال پنج نماز در شبانه‌روز واجب کرده و برای هر نماز وقت معینی قرار داده، پس هر کس آن‌ها را در وقتشان بخواند و رکوع و سجود و خشوعشان را کامل کند، برایش نور و برهان و نجات در روز قیامت خواهد بود.'''}, + {'language_code': 'en', 'title': '''The Messenger of Allah said: Prayer is the pillar of religion, if it is accepted, other deeds are accepted, and if it is rejected, other deeds are rejected. It is the first thing for which a servant will be held accountable on the Day of Judgment, and if it is sound, all his other deeds will be sound, and if it is corrupted, all his other deeds will be corrupted. + +Prayer is the ascension of the believer, it is the offering of every God-fearing person, and it is the love of Allah the Almighty. Whoever loves it and establishes it at its times and maintains its boundaries, Allah will raise him to the rank of the righteous. And whoever takes it lightly, wastes it and abandons it, has taken Allah's religion lightly, and has no share in Islam. + +Indeed, Allah the Almighty has prescribed five prayers in a day and night, and has appointed a specific time for each prayer. Whoever prays them at their time and completes their bowing, prostration and humility, they will be light, proof and salvation for him on the Day of Judgment.'''} + ], + 'explanation': '''Этот обширный хадис представляет собой фундаментальное учение о молитве в Исламе. Он раскрывает несколько ключевых аспектов: + +Во-первых, молитва описывается как "столп религии" (عمود الدين), что указывает на ее центральную роль в исламской вере. Это метафора подчеркивает, что как здание не может стоять без столпов, так и религиозная жизнь мусульманина не может быть полноценной без молитвы. + +Во-вторых, хадис устанавливает принцип, согласно которому принятие или отвержение молитвы Аллахом определяет судьбу всех остальных деяний верующего. Это подчеркивает качественный аспект молитвы - важна не только ее форма, но и искренность, концентрация и правильное выполнение. + +В-третьих, молитва представлена как "معراج المؤمن" (вознесение верующего), что отсылает к ночному путешествию Пророка (мир ему) и подчеркивает духовное измерение молитвы как средства приближения к Всевышнему. + +Хадис также подчеркивает важность своевременного совершения молитв и соблюдения их внешних и внутренних условий, обещая великую награду тем, кто относится к молитве с должным вниманием и уважением.''' + }, + { + 'title': 'Времена молитвы', + 'text': 'عن أبي عبد الله عليه السلام قال: إن لكل صلاة وقتين، وأول الوقت أفضل من آخره.', + 'translation': [ + {'language_code': 'ru', 'title': 'От Абу Абдуллаха (мир ему) сказал: Поистине, у каждой молитвы два времени, и первое время лучше последнего.'}, + {'language_code': 'fa', 'title': 'از امام صادق علیه السلام: هر نمازی دو وقت دارد و اول وقت بهتر از آخر آن است.'}, + {'language_code': 'en', 'title': 'From Imam Sadiq: Every prayer has two times, and the first time is better than the last.'} + ], + 'explanation': 'Этот хадис касается времен молитвы и достоинства совершения молитвы в первое время.' + }, + { + 'title': 'Коллективная молитва и ее великая награда', + 'text': '''قال رسول الله صلى الله عليه وآله: صلاة الجماعة تفضل صلاة الفذ بسبع وعشرين درجة. وما من رجل يؤم قوماً إلا غفر له ما تقدم من ذنبه وما تأخر، وما من رجل يصلي خلف إمام إلا كتب له مثل أجر الإمام من غير أن ينقص من أجر الإمام شيء. + +وإن الملائكة لتصلي على الصف الأول، وإن الشيطان ليفر من صوت الأذان، وإن الله تعالى يباهي بالمصلين ملائكته. ومن صلى في جماعة أربعين يوماً لا تفوته التكبيرة الأولى كتب له براءتان: براءة من النار وبراءة من النفاق. + +فاحرصوا على صلاة الجماعة، فإنها من شعائر الإسلام العظيمة، وهي سبب للألفة والمحبة بين المؤمنين، وفيها تظهر وحدة الأمة وقوتها.''', + 'translation': [ + {'language_code': 'ru', 'title': '''Сказал Посланник Аллаха (да благословит Аллах его и его семейство): Коллективная молитва превосходит индивидуальную молитву на двадцать семь степеней. И нет человека, который бы руководил молитвой людей, кроме как ему прощаются его прошлые и будущие грехи, и нет человека, который молился бы за имамом, кроме как ему записывается такая же награда, как имаму, не уменьшая награды имама ни на что. + +Поистине, ангелы молятся за первый ряд, и шайтан убегает от звука азана, и Всевышний Аллах гордится молящимися перед Своими ангелами. И кто молился в коллективе сорок дней, не пропуская первый такбир, тому записываются два освобождения: освобождение от огня и освобождение от лицемерия. + +Так стремитесь к коллективной молитве, ибо она из великих обрядов Ислама, и она причина единства и любви между верующими, и в ней проявляется единство уммы и ее сила.'''}, + {'language_code': 'fa', 'title': '''رسول خدا فرمود: نماز جماعت بر نماز فردی بیست و هفت درجه برتری دارد. و هیچ مردی نیست که امامت قومی کند مگر اینکه گناهان گذشته و آینده‌اش آمرزیده می‌شود، و هیچ مردی نیست که پشت سر امام نماز بخواند مگر اینکه مثل پاداش امام برایش نوشته می‌شود بدون اینکه از پاداش امام چیزی کم شود. + +همانا فرشتگان بر صف اول درود می‌فرستند و شیطان از صدای اذان فرار می‌کند و خداوند متعال به نمازگزاران در برابر فرشتگانش فخر می‌کند. و هر کس چهل روز در جماعت نماز بخواند که تکبیر اول را از دست ندهد، دو برائت برایش نوشته می‌شود: برائت از آتش و برائت از نفاق. + +پس بر نماز جماعت حریص باشید که از شعائر بزرگ اسلام است و سبب الفت و محبت میان مؤمنان است و در آن وحدت امت و قدرتش ظاهر می‌شود.'''}, + {'language_code': 'en', 'title': '''The Messenger of Allah said: Congregational prayer is superior to individual prayer by twenty-seven degrees. There is no man who leads people in prayer except that his past and future sins are forgiven, and there is no man who prays behind an imam except that the same reward as the imam is written for him without diminishing anything from the imam's reward. + +Indeed, the angels pray for the first row, and Satan flees from the sound of the call to prayer, and Allah the Almighty boasts of those who pray to His angels. And whoever prays in congregation for forty days without missing the first takbir, two clearances are written for him: clearance from the Fire and clearance from hypocrisy. + +So be keen on congregational prayer, for it is one of the great rituals of Islam, and it is a cause of harmony and love among believers, and in it the unity and strength of the ummah is manifested.'''} + ], + 'explanation': '''Этот развернутый хадис раскрывает множественные аспекты коллективной молитвы в Исламе и ее огромное духовное значение. + +Прежде всего, хадис устанавливает количественное превосходство коллективной молитвы над индивидуальной - в двадцать семь раз. Это число не случайно и подчеркивает особую ценность единения верующих в поклонении. + +Особое внимание уделяется роли имама и участников коллективной молитвы. Имам получает прощение грехов, что подчеркивает ответственность руководства общиной в молитве. Участники же получают равную награду с имамом, что демонстрирует справедливость божественного воздаяния. + +Хадис также упоминает о духовных реалиях, невидимых человеческому глазу: молитвы ангелов за первый ряд, бегство шайтана от азана, и гордость Всевышнего Своими поклоняющимися рабами. + +Особая награда обещана тем, кто постоянно участвует в коллективной молитве в течение сорока дней - это освобождение от огня и от лицемерия, что указывает на очищающую силу регулярного коллективного поклонения. + +Наконец, хадис подчеркивает социальный аспект коллективной молитвы как средства укрепления единства мусульманской общины.''' + }, + { + 'title': 'Времена молитв и их сохранение', + 'text': '''عن أبي عبد الله عليه السلام قال: إن لكل صلاة وقتين، وأول الوقت أفضل من آخره إلا في العشاء الآخرة، فإن أفضل وقتها إذا ذهب ثلث الليل. وقال: من صلى في أول الوقت فقد أدرك فضل الوقت، ومن صلى في آخر الوقت فقد أدرك الوقت. + +وحافظوا على الصلوات والصلاة الوسطى وقوموا لله قانتين. والصلاة الوسطى هي صلاة الظهر، وهي أول صلاة صلاها رسول الله صلى الله عليه وآله، وهي وسط النهار. + +إن الله تعالى جعل لكل صلاة علامات يعرف بها وقتها: فالفجر عند طلوع الفجر الصادق، والظهر عند زوال الشمس، والعصر عند صيرورة ظل كل شيء مثله، والمغرب عند غروب الشمس، والعشاء عند ذهاب الشفق الأحمر.''', + 'translation': [ + {'language_code': 'ru', 'title': '''От Абу Абдуллаха (мир ему): Поистине, у каждой молитвы два времени, и первое время лучше последнего, кроме ночной молитвы (иша), ибо лучшее время для нее - когда пройдет треть ночи. И сказал: кто помолился в первое время, тот достиг достоинства времени, а кто помолился в последнее время, тот достиг времени. + +Соблюдайте молитвы и среднюю молитву, и стойте перед Аллахом смиренно. Средняя молитва - это полуденная молитва, и это первая молитва, которую совершил Посланник Аллаха (да благословит Аллах его и его семейство), и она в середине дня. + +Поистине, Всевышний Аллах установил для каждой молитвы признаки, по которым узнается ее время: утренняя - при появлении истинного рассвета, полуденная - при прохождении солнца через зенит, послеполуденная - когда тень каждой вещи становится равной ей, вечерняя - при заходе солнца, ночная - при исчезновении красной зари.'''}, + {'language_code': 'fa', 'title': '''از امام صادق علیه السلام: همانا هر نمازی دو وقت دارد و اول وقت بهتر از آخر آن است مگر نماز عشاء که بهترین وقتش زمانی است که یک سوم شب بگذرد. و فرمود: هر کس در اول وقت نماز بخواند فضیلت وقت را درک کرده و هر کس در آخر وقت بخواند وقت را درک کرده است. + +بر نمازها و نماز وسطی محافظت کنید و برای خدا فروتنانه بایستید. نماز وسطی همان نماز ظهر است و این اولین نمازی است که رسول خدا صلی الله علیه و آله خواند و در وسط روز است. + +خداوند متعال برای هر نماز علامت‌هایی قرار داده که وقتش با آن شناخته می‌شود: نماز صبح هنگام طلوع فجر صادق، نماز ظهر هنگام زوال آفتاب، نماز عصر هنگامی که سایه هر چیز مثل خودش شود، نماز مغرب هنگام غروب آفتاب، نماز عشاء هنگام رفتن شفق سرخ.'''}, + {'language_code': 'en', 'title': '''From Abu Abdullah (peace be upon him): Indeed, every prayer has two times, and the first time is better than the last, except for the night prayer (Isha), for its best time is when a third of the night has passed. And he said: whoever prays at the first time has achieved the virtue of time, and whoever prays at the last time has achieved the time. + +Maintain the prayers and the middle prayer, and stand before Allah humbly. The middle prayer is the noon prayer, and it is the first prayer that the Messenger of Allah (may Allah bless him and his family) prayed, and it is in the middle of the day. + +Indeed, Allah the Almighty has established signs for each prayer by which its time is known: the dawn prayer at the appearance of true dawn, the noon prayer when the sun passes through the zenith, the afternoon prayer when the shadow of everything becomes equal to it, the evening prayer at sunset, the night prayer when the red twilight disappears.'''} + ], + 'explanation': '''Этот хадис представляет собой подробное руководство по временам молитв и их правильному соблюдению, что является одним из важнейших аспектов исламского поклонения. + +Хадис начинается с установления общего принципа о том, что каждая молитва имеет два времени - предпочтительное (первое) и допустимое (до конца времени). Это дает верующим гибкость в совершении молитв, учитывая различные жизненные обстоятельства, но при этом поощряет стремление к совершенству. + +Особое исключение делается для ночной молитвы (иша), для которой предпочтительное время наступает после прохождения трети ночи. Это связано с тем, что в это время достигается большее духовное сосредоточение и меньше мирских отвлечений. + +Хадис цитирует коранический аят о "средней молитве" и идентифицирует ее как полуденную молитву (зухр), подчеркивая ее особое значение как первой молитвы, установленной Пророком. + +Наконец, хадис предоставляет практическое руководство по определению времен молитв через естественные признаки, что было особенно важно в эпоху до появления точных часов. Эти признаки основаны на движении солнца и изменении освещения, что делает их универсально применимыми в любой географической точке.''' + } + ], + 'fasting': [ + { + 'title': 'Достоинство поста и его духовные плоды', + 'text': '''قال النبي صلى الله عليه وآله: الصوم جنة من النار، وهو زكاة البدن، وصوم شهر الصبر وثلاثة أيام من كل شهر يذهبن وحر الصدر ووساوس الشيطان. ومن صام يوماً في سبيل الله باعد الله وجهه عن النار سبعين خريفاً. + +إن الصائم في عبادة وإن كان نائماً على فراشه، وإن دعاءه مستجاب حتى يفطر، وإن الملائكة تستغفر له حتى يفطر. وللصائم فرحتان: فرحة عند فطره، وفرحة عند لقاء ربه. + +يا معشر الشباب، من استطاع منكم الباءة فليتزوج، ومن لم يستطع فعليه بالصوم فإنه له وجاء. والصوم يكسر الشهوة ويطهر النفس ويقرب إلى الله تعالى.''', + 'translation': [ + {'language_code': 'ru', 'title': '''Сказал Пророк (да благословит Аллах его и его семейство): Пост - щит от огня, и он закят тела, и пост месяца терпения и трех дней каждого месяца устраняют жар груди и наущения шайтана. И кто постился день на пути Аллаха, Аллах отдалит его лицо от огня на семьдесят осеней. + +Поистине, постящийся находится в поклонении, даже если он спит на своей постели, и его мольба принимается до тех пор, пока он не разговеется, и ангелы просят прощения для него до тех пор, пока он не разговеется. И у постящегося две радости: радость при разговении и радость при встрече со своим Господом. + +О молодежь! Кто из вас способен жениться, пусть женится, а кто не способен, пусть постится, ибо это для него защита. Пост ломает страсть, очищает душу и приближает к Всевышнему Аллаху.'''}, + {'language_code': 'fa', 'title': '''پیامبر فرمود: روزه سپری است از آتش جهنم و زکات بدن است و روزه ماه صبر و سه روز از هر ماه، حرارت سینه و وسوسه‌های شیطان را می‌برد. و هر کس روزی در راه خدا روزه بگیرد، خداوند صورتش را از آتش به اندازه هفتاد پاییز دور می‌کند. + +روزه‌دار در عبادت است اگرچه بر بسترش خوابیده باشد و دعایش تا افطار مستجاب است و فرشتگان تا افطار برایش استغفار می‌کنند. و روزه‌دار را دو شادی است: شادی هنگام افطار و شادی هنگام ملاقات پروردگارش. + +ای جوانان! هر کس از شما توانایی ازدواج دارد باید ازدواج کند و هر کس نتواند باید روزه بگیرد که برایش محافظ است. روزه شهوت را می‌شکند و نفس را پاک می‌کند و به خداوند متعال نزدیک می‌کند.'''}, + {'language_code': 'en', 'title': '''The Prophet said: Fasting is a shield from the Fire, and it is the zakat of the body, and fasting the month of patience and three days of each month removes the heat of the chest and the whispers of Satan. And whoever fasts a day in the way of Allah, Allah will distance his face from the Fire by seventy autumns. + +Indeed, the fasting person is in worship even if he is sleeping on his bed, and his supplication is answered until he breaks his fast, and the angels seek forgiveness for him until he breaks his fast. And the fasting person has two joys: joy when he breaks his fast, and joy when he meets his Lord. + +O young people! Whoever among you is able to marry, let him marry, and whoever is not able, let him fast, for it is a protection for him. Fasting breaks desire, purifies the soul, and brings one closer to Allah the Almighty.'''} + ], + 'explanation': '''Этот многогранный хадис раскрывает глубокие духовные и практические аспекты поста в Исламе, представляя его как комплексную систему духовного и физического очищения. + +Хадис начинается с определения поста как "щита от огня" (جنة من النار), что указывает на его защитную функцию от грехов и их последствий. Понятие "закят тела" (زكاة البدن) представляет пост как форму очищения физического тела, аналогичную тому, как закят очищает имущество. + +Особое внимание уделяется посту "месяца терпения" (شهر الصبر) - Рамадана, и дополнительным постам трех дней каждого месяца. Эти посты описываются как средство устранения "жара груди" - метафоры внутреннего беспокойства, гнева и негативных эмоций. + +Хадис подчеркивает непрерывность духовного состояния постящегося - даже во сне он остается в состоянии поклонения. Это указывает на то, что пост - это не просто воздержание от еды и питья, но целостное духовное состояние. + +Упоминание о двух радостях постящегося - при разговении и при встрече с Аллахом - показывает как временные, так и вечные плоды поста. + +Наконец, хадис связывает пост с контролем над страстями, особенно рекомендуя его молодым людям как средство духовной защиты и самодисциплины.''' + }, + { + 'title': 'Пост в месяц Рамадан', + 'text': 'عن أبي عبد الله عليه السلام قال: من صام شهر رمضان إيماناً واحتساباً غفر له ما تقدم من ذنبه.', + 'translation': [ + {'language_code': 'ru', 'title': 'От Абу Абдуллаха (мир ему): Кто постился в месяц Рамадан с верой и надеждой на награду, тому прощены его прошлые грехи.'}, + {'language_code': 'fa', 'title': 'از امام صادق علیه السلام: هر کس ماه رمضان را با ایمان و امید به پاداش روزه بگیرد، گناهان گذشته‌اش آمرزیده می‌شود.'}, + {'language_code': 'en', 'title': 'From Abu Abdullah: Whoever fasts the month of Ramadan with faith and hope for reward, his past sins are forgiven.'} + ], + 'explanation': 'Этот хадис говорит о великой награде за пост в месяц Рамадан.' + } + ], + 'ethics': [ + { + 'title': 'Благородный нрав', + 'text': 'قال رسول الله صلى الله عليه وآله: إنما بعثت لأتمم مكارم الأخلاق.', + 'translation': [ + {'language_code': 'ru', 'title': 'Сказал Посланник Аллаха (да благословит Аллах его и его семейство): Поистине, я послан, чтобы довести до совершенства благородные нравы.'}, + {'language_code': 'fa', 'title': 'رسول خدا فرمود: من فقط برای تکمیل مکارم اخلاق مبعوث شده‌ام.'}, + {'language_code': 'en', 'title': 'The Messenger of Allah said: I was sent only to perfect noble character.'} + ], + 'explanation': 'Этот хадис показывает важность нравственности в исламской религии.' + }, + { + 'title': 'Терпение', + 'text': 'عن أمير المؤمنين عليه السلام: الصبر صبران: صبر على ما تكره، وصبر عما تحب.', + 'translation': [ + {'language_code': 'ru', 'title': 'От Повелителя верующих (мир ему): Терпение бывает двух видов: терпение к тому, что ты не любишь, и терпение от того, что ты любишь.'}, + {'language_code': 'fa', 'title': 'از امیرمؤمنان علیه السلام: صبر دو گونه است: صبر بر آنچه دوست نداری و صبر از آنچه دوست داری.'}, + {'language_code': 'en', 'title': 'From Amir al-Muminin: Patience is of two types: patience with what you dislike, and patience from what you love.'} + ], + 'explanation': 'Этот хадис описывает виды терпения.' + }, + { + 'title': 'Справедливость', + 'text': 'قال رسول الله صلى الله عليه وآله: العدل أساس الملك.', + 'translation': [ + {'language_code': 'ru', 'title': 'Сказал Посланник Аллаха (да благословит Аллах его и его семейство): Справедливость - основа власти.'}, + {'language_code': 'fa', 'title': 'رسول خدا فرمود: عدالت اساس حکومت است.'}, + {'language_code': 'en', 'title': 'The Messenger of Allah said: Justice is the foundation of governance.'} + ], + 'explanation': 'Этот хадис подчеркивает важность справедливости в управлении.' + }, + { + 'title': 'Знание', + 'text': 'قال النبي صلى الله عليه وآله: اطلبوا العلم من المهد إلى اللحد.', + 'translation': [ + {'language_code': 'ru', 'title': 'Сказал Пророк (да благословит Аллах его и его семейство): Ищите знания от колыбели до могилы.'}, + {'language_code': 'fa', 'title': 'پیامبر فرمود: علم را از گهواره تا گور بجویید.'}, + {'language_code': 'en', 'title': 'The Prophet said: Seek knowledge from the cradle to the grave.'} + ], + 'explanation': 'Этот хадис призывает к постоянному поиску знаний на протяжении всей жизни.' + } + ], + 'quran': [ + { + 'title': 'Достоинство чтения Корана', + 'text': 'قال النبي صلى الله عليه وآله: اقرؤوا القرآن فإنه يأتي يوم القيامة شفيعاً لأصحابه.', + 'translation': [ + {'language_code': 'ru', 'title': 'Сказал Пророк (да благословит Аллах его и его семейство): Читайте Коран, ибо он придет в День Воскресения заступником за своих сподвижников.'}, + {'language_code': 'fa', 'title': 'پیامبر فرمود: قرآن بخوانید که در روز قیامت شفیع اصحابش خواهد بود.'}, + {'language_code': 'en', 'title': 'The Prophet said: Read the Quran, for it will come on the Day of Judgment as an intercessor for its companions.'} + ], + 'explanation': 'Этот хадис описывает достоинство чтения Священного Корана.' + }, + { + 'title': 'Изучение Корана', + 'text': 'عن أبي عبد الله عليه السلام قال: خيركم من تعلم القرآن وعلمه.', + 'translation': [ + {'language_code': 'ru', 'title': 'От Абу Абдуллаха (мир ему): Лучший из вас тот, кто изучает Коран и обучает ему.'}, + {'language_code': 'fa', 'title': 'از امام صادق علیه السلام: بهترین شما کسی است که قرآن بیاموزد و آن را تعلیم دهد.'}, + {'language_code': 'en', 'title': 'From Abu Abdullah: The best of you is the one who learns the Quran and teaches it.'} + ], + 'explanation': 'Этот хадис подчеркивает важность изучения и обучения Корану.' + }, + { + 'title': 'Размышление над Кораном', + 'text': 'قال أمير المؤمنين عليه السلام: تدبروا آيات القرآن واعتبروا به.', + 'translation': [ + {'language_code': 'ru', 'title': 'Сказал Повелитель верующих (мир ему): Размышляйте над аятами Корана и извлекайте из него уроки.'}, + {'language_code': 'fa', 'title': 'امیرمؤمنان علیه السلام فرمود: در آیات قرآن تدبر کنید و از آن عبرت بگیرید.'}, + {'language_code': 'en', 'title': 'Amir al-Muminin said: Contemplate the verses of the Quran and take lessons from it.'} + ], + 'explanation': 'Этот хадис призывает к глубокому размышлению над аятами Корана.' + } + ] + } + + hadis_records = [] + hadis_number = 1 + + # Batch processing for hadis creation + hadis_to_create = [] + hadis_to_update = [] + hadis_tags_to_set = [] + + print("Processing categories in batches...") + + # Create hadis for each leaf category (10 hadis per category) + total_categories = len(leaf_categories) + for idx, category in enumerate(leaf_categories, 1): + print(f" Processing category {idx}/{total_categories}: {category.title}") + + # Get existing hadis for this category to avoid duplicates + existing_hadis = { + h.number: h for h in Hadis.objects.filter(category=category) + } + + # Determine hadis type based on category title + hadis_type = 'ethics' # default + if 'молитв' in category.title.lower() or 'намаз' in category.title.lower(): + hadis_type = 'prayer' + elif 'пост' in category.title.lower() or 'روزه' in category.title: + hadis_type = 'fasting' + elif 'нравственност' in category.title.lower() or 'اخلاق' in category.title: + hadis_type = 'ethics' + elif 'коран' in category.title.lower() or 'толкован' in category.title.lower(): + hadis_type = 'quran' + + # Create exactly 10 hadis per leaf category + available_samples = hadis_samples.get(hadis_type, hadis_samples['ethics']) + + for i in range(10): + sample = random.choice(available_samples) + + # Generate varied links based on hadis type and number + links = [] + if hadis_type == 'prayer': + links = [ + {'title': 'Книги о молитве', 'link': f'https://islamicbooks.ru/prayer/{hadis_number}'}, + {'title': 'Времена молитв', 'link': 'https://prayertimes.ru'}, + {'title': 'Руководство по молитве', 'link': 'https://salah-guide.ru'} + ] + elif hadis_type == 'fasting': + links = [ + {'title': 'Книги о посте', 'link': f'https://islamicbooks.ru/fasting/{hadis_number}'}, + {'title': 'Календарь поста', 'link': 'https://ramadan-calendar.ru'}, + {'title': 'Правила поста', 'link': 'https://fasting-rules.ru'} + ] + elif hadis_type == 'quran': + links = [ + {'title': 'Толкование Корана', 'link': f'https://quran-tafsir.ru/hadis/{hadis_number}'}, + {'title': 'Текст Корана', 'link': 'https://quran-text.ru'}, + {'title': 'Аудио Коран', 'link': 'https://quran-audio.ru'} + ] + else: # ethics + links = [ + {'title': 'Исламская этика', 'link': f'https://islamic-ethics.ru/hadis/{hadis_number}'}, + {'title': 'Нравственные учения', 'link': 'https://moral-teachings.ru'}, + {'title': 'Духовное развитие', 'link': 'https://spiritual-growth.ru'} + ] + + # Check if hadis already exists + if hadis_number in existing_hadis: + hadis = existing_hadis[hadis_number] + # Check if update is needed + updated = False + if (hadis.title != sample['title'] or + hadis.text != sample['text'] or + hadis.translation != sample['translation'] or + hadis.explanation != sample.get('explanation', '') or + hadis.links != links): + + hadis.title = sample['title'] + hadis.text = sample['text'] + hadis.translation = sample['translation'] + hadis.explanation = sample.get('explanation', '') + hadis.links = links + hadis_to_update.append(hadis) + updated = True + + if updated: + print(f" Will update hadis #{hadis.number}: {hadis.title}") + else: + print(f" Hadis #{hadis.number} already exists and is up to date") + + # Add tags if needed + if not hadis.tags.exists(): + selected_tags = random.sample(tags, random.randint(2, 5)) + hadis_tags_to_set.append((hadis, selected_tags)) + + else: + # Create new hadis + hadis = Hadis( + category=category, + number=hadis_number, + title=sample['title'], + text=sample['text'], + translation=sample['translation'], + status=True, + hadis_status=random.choice(statuses), + hadis_status_text="Приведен в достоверных книгах", + address=f"Книга {category.title}, хадис {hadis_number}", + explanation=sample.get('explanation', ''), + links=links + ) + hadis_to_create.append(hadis) + + # Prepare tags for later assignment + selected_tags = random.sample(tags, random.randint(2, 5)) + hadis_tags_to_set.append((hadis, selected_tags)) + + print(f" Will create hadis #{hadis_number}: {hadis.title}") + + hadis_records.append(hadis) + hadis_number += 1 + + # Batch operations every 50 hadis or at end of category + if len(hadis_to_create) >= 50 or len(hadis_to_update) >= 50: + self._perform_hadis_batch_operations(hadis_to_create, hadis_to_update, hadis_tags_to_set) + hadis_to_create = [] + hadis_to_update = [] + hadis_tags_to_set = [] + + # Final batch operations + if hadis_to_create or hadis_to_update: + self._perform_hadis_batch_operations(hadis_to_create, hadis_to_update, hadis_tags_to_set) + + return hadis_records + + def _perform_hadis_batch_operations(self, hadis_to_create, hadis_to_update, hadis_tags_to_set): + """Perform batch database operations for hadis""" + + # Batch create + if hadis_to_create: + Hadis.objects.bulk_create(hadis_to_create, ignore_conflicts=True) + print(f" Batch created {len(hadis_to_create)} hadis records") + + # Batch update + if hadis_to_update: + Hadis.objects.bulk_update( + hadis_to_update, + ['title', 'text', 'translation', 'explanation', 'links'] + ) + print(f" Batch updated {len(hadis_to_update)} hadis records") + + # Set tags (this needs to be done after creation/update) + if hadis_tags_to_set: + for hadis, tags_list in hadis_tags_to_set: + # For newly created hadis, we need to get the actual object from DB + if not hadis.pk: + try: + hadis = Hadis.objects.get(category=hadis.category, number=hadis.number) + except Hadis.DoesNotExist: + continue + hadis.tags.set(tags_list) + print(f" Set tags for {len(hadis_tags_to_set)} hadis records") + + def seed_hadis_transmitters(self, hadis_records, transmitters, statuses): + """Create HadisTransmitter records (transmission chains) - optimized batch creation""" + print("Creating Hadis Transmitters (chains)...") + + transmitter_chains = [] + chains_to_create = [] + + + # Get hadis that already have transmitters to avoid duplicates + hadis_with_transmitters = set( + HadisTransmitter.objects.values_list('hadis_id', flat=True).distinct() + ) + + print(f"Processing {len(hadis_records)} hadis records...") + + for hadis in hadis_records: + # Skip if this hadis already has transmitters + if hadis.id in hadis_with_transmitters: + print(f" Hadis #{hadis.number} already has transmitters, skipping...") + continue + + # Create a transmission chain of 3-6 transmitters + chain_length = random.randint(3, 6) + selected_transmitters = random.sample(transmitters, min(chain_length, len(transmitters))) + + for order, transmitter in enumerate(selected_transmitters, 1): + # Occasionally create gaps in the chain + is_gap = random.choice([False, False, False, True]) # 25% chance of gap + + chain = HadisTransmitter( + hadis=hadis, + order=order, + transmitter=transmitter if not is_gap else None, + status=random.choice(statuses), + is_gap=is_gap + ) + chains_to_create.append(chain) + transmitter_chains.append(chain) + + if is_gap: + print(f" Will add gap in chain for hadis #{hadis.number} at position {order}") + else: + print(f" Will add transmitter {transmitter.full_name} to hadis #{hadis.number}") + + # Batch create every 100 chains + if len(chains_to_create) >= 100: + HadisTransmitter.objects.bulk_create(chains_to_create, ignore_conflicts=True) + print(f" Batch created {len(chains_to_create)} transmitter chains") + chains_to_create = [] + + # Final batch create + if chains_to_create: + HadisTransmitter.objects.bulk_create(chains_to_create, ignore_conflicts=True) + print(f" Final batch created {len(chains_to_create)} transmitter chains") + + return transmitter_chains + + def seed_hadis_references(self, hadis_records, books): + """Create HadisReference records - optimized batch creation""" + print("Creating Hadis References...") + + references = [] + references_to_create = [] + references_to_update = [] + + # Get existing references to avoid duplicates + existing_references = {} + for ref in HadisReference.objects.select_related('hadis', 'book').all(): + key = (ref.hadis_id, ref.book_id) + existing_references[key] = ref + + print(f"Processing references for {len(hadis_records)} hadis records...") + + for hadis in hadis_records: + # Each hadis can have 1-3 references + num_refs = random.randint(1, 3) + selected_books = random.sample(books, min(num_refs, len(books))) + + for book in selected_books: + key = (hadis.id, book.id) + new_description = f"Источник хадиса номер {hadis.number} в книге {book.title}" + + if key in existing_references: + reference = existing_references[key] + if reference.description != new_description: + reference.description = new_description + references_to_update.append(reference) + print(f" Will update reference: Hadis #{hadis.number} -> {book.title}") + else: + print(f" Reference already exists: Hadis #{hadis.number} -> {book.title}") + else: + reference = HadisReference( + hadis=hadis, + book=book, + description=new_description + ) + references_to_create.append(reference) + print(f" Will create reference: Hadis #{hadis.number} -> {book.title}") + + references.append(reference) + + # Batch operations every 100 references + if len(references_to_create) >= 100: + HadisReference.objects.bulk_create(references_to_create, ignore_conflicts=True) + print(f" Batch created {len(references_to_create)} references") + references_to_create = [] + + if len(references_to_update) >= 100: + HadisReference.objects.bulk_update(references_to_update, ['description']) + print(f" Batch updated {len(references_to_update)} references") + references_to_update = [] + + # Final batch operations + if references_to_create: + HadisReference.objects.bulk_create(references_to_create, ignore_conflicts=True) + print(f" Final batch created {len(references_to_create)} references") + + if references_to_update: + HadisReference.objects.bulk_update(references_to_update, ['description']) + print(f" Final batch updated {len(references_to_update)} references") + + return references + + def seed_reference_images(self, references): + """Create ReferenceImage records""" + print("Creating Reference Images...") + + reference_images = [] + + for reference in references: + # 50% chance to add images to reference + if random.choice([True, False]): + # Add 1-2 images per reference + num_images = random.randint(1, 2) + + for i in range(num_images): + image_file = random.choice(self.image_files) + + ref_image = ReferenceImage.objects.create( + reference=reference, + priority=i + ) + + try: + with open(image_file, 'rb') as f: + ref_image.thumbnail.save( + f"ref_image_{ref_image.id}.png", + File(f), + save=True + ) + reference_images.append(ref_image) + print(f" Added image to reference: {reference.hadis.title}") + except Exception as e: + print(f" Warning: Could not add image to reference: {e}") + ref_image.delete() + + return reference_images + + @transaction.atomic + def run_seeding(self, clear_existing=False): + """Main method to run all seeding operations""" + print("=" * 60) + print("STARTING HADIS DATA SEEDING") + print("=" * 60) + + try: + if clear_existing: + self.clear_existing_data() + + # Step 1: Create basic lookup data + print("\n" + "=" * 40) + print("STEP 1: Creating basic lookup data") + print("=" * 40) + + statuses = self.seed_hadis_statuses() + tags = self.seed_hadis_tags() + sects = self.seed_hadis_sects() + + # Step 2: Create hierarchical categories + print("\n" + "=" * 40) + print("STEP 2: Creating hierarchical categories") + print("=" * 40) + + categories = self.seed_hadis_categories(sects) + + # Step 3: Create library data for references + print("\n" + "=" * 40) + print("STEP 3: Creating library data") + print("=" * 40) + + books, _, _ = self.seed_library_data() + + # Step 4: Create transmitters + print("\n" + "=" * 40) + print("STEP 4: Creating transmitters") + print("=" * 40) + + transmitters = self.seed_transmitters() + + # Step 5: Create hadis records + print("\n" + "=" * 40) + print("STEP 5: Creating hadis records") + print("=" * 40) + + hadis_records = self.seed_hadis_records(categories, statuses, tags, transmitters, books) + + # Step 6: Create transmission chains + print("\n" + "=" * 40) + print("STEP 6: Creating transmission chains") + print("=" * 40) + + transmitter_chains = self.seed_hadis_transmitters(hadis_records, transmitters, statuses) + + # Step 7: Create references + print("\n" + "=" * 40) + print("STEP 7: Creating references") + print("=" * 40) + + references = self.seed_hadis_references(hadis_records, books) + + # Step 8: Create reference images + print("\n" + "=" * 40) + print("STEP 8: Creating reference images") + print("=" * 40) + + reference_images = self.seed_reference_images(references) + + # Summary + print("\n" + "=" * 60) + print("SEEDING COMPLETED SUCCESSFULLY!") + print("=" * 60) + print(f"Created:") + print(f" - {len(sects)} Hadis Sects") + print(f" - {len(categories)} Hadis Categories") + print(f" - {len(statuses)} Hadis Statuses") + print(f" - {len(tags)} Hadis Tags") + print(f" - {len(transmitters)} Transmitters") + print(f" - {len(hadis_records)} Hadis Records") + print(f" - {len(transmitter_chains)} Transmitter Chain Links") + print(f" - {len(books)} Books") + print(f" - {len(references)} Hadis References") + print(f" - {len(reference_images)} Reference Images") + print("=" * 60) + + return { + 'sects': sects, + 'categories': categories, + 'statuses': statuses, + 'tags': tags, + 'transmitters': transmitters, + 'hadis_records': hadis_records, + 'transmitter_chains': transmitter_chains, + 'books': books, + 'references': references, + 'reference_images': reference_images + } + + except Exception as e: + print(f"\nERROR during seeding: {e}") + print("Rolling back transaction...") + raise + + +def main(): + """Main function to run the seeding script""" + import argparse + + parser = argparse.ArgumentParser(description='Seed Hadis app with sample data') + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing data before seeding' + ) + parser.add_argument( + '--no-clear', + action='store_true', + help='Do not clear existing data (default)' + ) + + args = parser.parse_args() + + # Default to not clearing unless explicitly requested + clear_existing = args.clear and not args.no_clear + + if clear_existing: + response = input("This will DELETE all existing Hadis data. Are you sure? (yes/no): ") + if response.lower() != 'yes': + print("Seeding cancelled.") + return + + try: + seeder = HadisDataSeeder() + seeder.run_seeding(clear_existing=clear_existing) + + print("\n✅ Seeding completed successfully!") + print("You can now test the APIs:") + print(" - GET /api/hadis/sects/") + print(" - GET /api/hadis/sect/{sect_id}/categories/") + print(" - GET /api/hadis/category/{category_id}/hadis/") + + except Exception as e: + print(f"\n❌ Seeding failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/seed_images/book1.png b/scripts/seed_images/book1.png new file mode 100644 index 0000000..1aca516 Binary files /dev/null and b/scripts/seed_images/book1.png differ diff --git a/scripts/seed_images/book2.png b/scripts/seed_images/book2.png new file mode 100644 index 0000000..e1be487 Binary files /dev/null and b/scripts/seed_images/book2.png differ diff --git a/scripts/seed_images/book3.png b/scripts/seed_images/book3.png new file mode 100644 index 0000000..87d6ff9 Binary files /dev/null and b/scripts/seed_images/book3.png differ diff --git a/scripts/seed_images/book4.png b/scripts/seed_images/book4.png new file mode 100644 index 0000000..8788993 Binary files /dev/null and b/scripts/seed_images/book4.png differ diff --git a/scripts/test.xmind b/scripts/test.xmind new file mode 100755 index 0000000..f53fb0d Binary files /dev/null and b/scripts/test.xmind differ diff --git a/scripts/test_webhook.py b/scripts/test_webhook.py new file mode 100755 index 0000000..120f8c9 --- /dev/null +++ b/scripts/test_webhook.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Test script for PlugNMeet webhook endpoint. + +Usage: + python scripts/test_webhook.py [event_type] + +Event types: + - room_finished + - participant_joined + - participant_left + - end_recording +""" + +import sys +import os +import json +import hmac +import hashlib +import requests +from datetime import datetime + +# Django setup +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +import django +django.setup() + +from django.conf import settings + + +def calculate_signature(payload: str, secret: str) -> str: + """Calculate HMAC SHA256 signature.""" + return hmac.new( + secret.encode('utf-8'), + payload.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + +def get_webhook_url() -> str: + """Get webhook URL from settings or use default.""" + base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') + return f"{base_url}/api/course/plugnmeet/webhook/" + + +def get_test_payload(event_type: str) -> dict: + """Generate test payload for given event type.""" + timestamp = int(datetime.now().timestamp()) + + payloads = { + 'room_finished': { + "event": "room_finished", + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": timestamp, + "room": { + "sid": "room-123456", + "identity": "test-room-20240101120000", + "name": "Test Class", + "maxParticipants": 100, + "creationTime": timestamp - 3600, + "metadata": "{}", + "numParticipants": 0, + "duration": 3600 + } + }, + 'participant_joined': { + "event": "participant_joined", + "id": "660e8400-e29b-41d4-a716-446655440001", + "createdAt": timestamp, + "room": { + "sid": "room-123456", + "identity": "test-room-20240101120000", + "name": "Test Class" + }, + "participant": { + "sid": "participant-user-1", + "identity": "1", + "state": "ACTIVE", + "name": "Test User", + "metadata": '{"is_admin": false}', + "permission": { + "canPublish": True, + "canPublishData": True, + "canSubscribe": True + }, + "tracks": [], + "joinedAt": timestamp + } + }, + 'participant_left': { + "event": "participant_left", + "id": "770e8400-e29b-41d4-a716-446655440002", + "createdAt": timestamp, + "room": { + "sid": "room-123456", + "identity": "test-room-20240101120000", + "name": "Test Class" + }, + "participant": { + "sid": "participant-user-1", + "identity": "1", + "state": "DISCONNECTED", + "name": "Test User", + "metadata": '{"is_admin": false}', + "permission": { + "canPublish": True, + "canPublishData": True, + "canSubscribe": True + }, + "tracks": [], + "joinedAt": timestamp - 1800, + "duration": 1800 + } + }, + 'end_recording': { + "event": "end_recording", + "id": "880e8400-e29b-41d4-a716-446655440003", + "createdAt": timestamp, + "room": { + "sid": "room-123456", + "identity": "test-room-20240101120000", + "name": "Test Class" + }, + "recording_info": { + "recordingId": "rec-123456", + "roomId": "test-room-20240101120000", + "recordingType": "COMPOSITE", + "fileName": "test-room-20240101120000.mp4", + "duration": 3600, + "status": "FINISHED" + } + } + } + + return payloads.get(event_type) + + +def send_webhook(event_type: str, dry_run: bool = False): + """Send webhook request to the endpoint.""" + # Get configuration + webhook_url = get_webhook_url() + api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', '') + + if not api_secret: + print("❌ Error: PLUGNMEET_API_SECRET not configured in settings") + return False + + # Get test payload + payload = get_test_payload(event_type) + if not payload: + print(f"❌ Error: Unknown event type '{event_type}'") + print("Available event types: room_finished, participant_joined, participant_left, end_recording") + return False + + # Convert payload to JSON string + payload_json = json.dumps(payload, ensure_ascii=False) + + # Calculate signature + signature = calculate_signature(payload_json, api_secret) + + # Print test information + print("\n" + "="*80) + print(f"🧪 Testing PlugNMeet Webhook: {event_type}") + print("="*80) + print(f"\n📍 URL: {webhook_url}") + print(f"\n📦 Payload:") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + print(f"\n🔐 Signature: {signature[:20]}...") + + if dry_run: + print("\n⚠️ DRY RUN - Not sending actual request") + return True + + # Send request + try: + headers = { + 'Content-Type': 'application/webhook+json', + 'Hash-Token': signature, + } + + print("\n📤 Sending request...") + response = requests.post( + webhook_url, + data=payload_json.encode('utf-8'), + headers=headers, + timeout=10 + ) + + print(f"\n✅ Response Status: {response.status_code}") + print(f"📄 Response Body:") + try: + print(json.dumps(response.json(), indent=2, ensure_ascii=False)) + except: + print(response.text) + + if response.status_code == 200: + print("\n✅ Webhook test successful!") + return True + else: + print(f"\n❌ Webhook test failed with status {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + print(f"\n❌ Error sending request: {e}") + return False + finally: + print("\n" + "="*80 + "\n") + + +def main(): + """Main function.""" + if len(sys.argv) < 2: + print("Usage: python scripts/test_webhook.py [event_type]") + print("\nAvailable event types:") + print(" - room_finished") + print(" - participant_joined") + print(" - participant_left") + print(" - end_recording") + print("\nOptions:") + print(" --dry-run Show payload without sending request") + sys.exit(1) + + event_type = sys.argv[1] + dry_run = '--dry-run' in sys.argv + + success = send_webhook(event_type, dry_run=dry_run) + sys.exit(0 if success else 1) + + +if __name__ == '__main__': + main() diff --git a/seeds/images/blog1.jpeg b/seeds/images/blog1.jpeg new file mode 100644 index 0000000..0843815 Binary files /dev/null and b/seeds/images/blog1.jpeg differ diff --git a/seeds/images/blog2.jpeg b/seeds/images/blog2.jpeg new file mode 100644 index 0000000..0ab39ed Binary files /dev/null and b/seeds/images/blog2.jpeg differ diff --git a/seeds/images/blog3.jpeg b/seeds/images/blog3.jpeg new file mode 100644 index 0000000..76c04a5 Binary files /dev/null and b/seeds/images/blog3.jpeg differ diff --git a/seeds/images/book1.png b/seeds/images/book1.png new file mode 100644 index 0000000..e1d8a8b Binary files /dev/null and b/seeds/images/book1.png differ diff --git a/seeds/images/book2.png b/seeds/images/book2.png new file mode 100644 index 0000000..508c035 Binary files /dev/null and b/seeds/images/book2.png differ diff --git a/seeds/images/book3.png b/seeds/images/book3.png new file mode 100644 index 0000000..1817624 Binary files /dev/null and b/seeds/images/book3.png differ diff --git a/seeds/images/book4.png b/seeds/images/book4.png new file mode 100644 index 0000000..f015699 Binary files /dev/null and b/seeds/images/book4.png differ diff --git a/seeds/images/book5.png b/seeds/images/book5.png new file mode 100644 index 0000000..e1d8a8b Binary files /dev/null and b/seeds/images/book5.png differ diff --git a/seeds/images/image 1208.png b/seeds/images/image 1208.png new file mode 100644 index 0000000..49d329d Binary files /dev/null and b/seeds/images/image 1208.png differ diff --git a/seeds/images/ref1.png b/seeds/images/ref1.png new file mode 100644 index 0000000..7e3e4e7 Binary files /dev/null and b/seeds/images/ref1.png differ diff --git a/seeds/images/ref2.png b/seeds/images/ref2.png new file mode 100644 index 0000000..49d329d Binary files /dev/null and b/seeds/images/ref2.png differ diff --git a/seeds/images/ref3.png b/seeds/images/ref3.png new file mode 100644 index 0000000..0a7a4b5 Binary files /dev/null and b/seeds/images/ref3.png differ diff --git a/seeds/images/ref4.png b/seeds/images/ref4.png new file mode 100644 index 0000000..49d329d Binary files /dev/null and b/seeds/images/ref4.png differ diff --git a/sshs b/sshs index dc0a152..c13f0f6 100644 --- a/sshs +++ b/sshs @@ -1,4 +1,8 @@ +Host 62.60.197.179 + HostName 62.60.197.179 + User ubuntu + Host 88.99.212.243 HostName 88.99.212.243 User nwhco - Port 1782 + Port 1782 \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..e971187 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,53 @@ +module.exports = { + content: ["./**/*.{html,py,js}"], + media: false, + darkMode: "class", + theme: { + extend: { + colors: { + primary: { + 50: "rgb(var(--color-primary-100) / )", + 100: "rgb(var(--color-primary-100) / )", + 200: "rgb(var(--color-primary-200) / )", + 300: "rgb(var(--color-primary-300) / )", + 400: "rgb(var(--color-primary-400) / )", + 500: "rgb(var(--color-primary-500) / )", + 600: "rgb(var(--color-primary-600) / )", + 700: "rgb(var(--color-primary-700) / )", + 800: "rgb(var(--color-primary-800) / )", + 900: "rgb(var(--color-primary-900) / )", + }, + }, + fontSize: { + 0: [0, 1], + xxs: ["11px", "14px"], + }, + fontFamily: { + sans: ["Inter", "sans-serif"], + }, + minWidth: { + sidebar: "18rem", + }, + spacing: { + 68: "17rem", + 128: "32rem", + }, + transitionProperty: { + height: "height", + width: "width", + }, + width: { + sidebar: "18rem", + }, + }, + }, + variants: { + extend: { + borderColor: ["checked", "focus-within", "hover"], + display: ["group-hover"], + overflow: ["hover"], + textColor: ["hover"], + }, + }, + plugins: [require("@tailwindcss/typography")], +}; diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 0000000..83e0b87 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,13 @@ +{% extends "admin/base.html" %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block branding %} + {% include "unfold/helpers/site_branding.html" %} +{% endblock %} + +{% block extrahead %} + {% if plausible_domain %} + + {% endif %} +{% endblock %} diff --git a/templates/admin/chat/chatmessage/change_list.html b/templates/admin/chat/chatmessage/change_list.html new file mode 100644 index 0000000..8c217de --- /dev/null +++ b/templates/admin/chat/chatmessage/change_list.html @@ -0,0 +1,12 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} + {{ block.super }} +
  • + + arrow_back + {% translate "Back to Chat Rooms" %} + +
  • +{% endblock %} \ No newline at end of file diff --git a/templates/admin/filer/folder/directory_table.html b/templates/admin/filer/folder/directory_table.html new file mode 100644 index 0000000..338bd17 --- /dev/null +++ b/templates/admin/filer/folder/directory_table.html @@ -0,0 +1,228 @@ +{% load i18n l10n admin_list filer_tags filer_admin_tags static %} +
    + diff --git a/templates/admin/helpers/kpi_progress.html b/templates/admin/helpers/kpi_progress.html new file mode 100644 index 0000000..871658c --- /dev/null +++ b/templates/admin/helpers/kpi_progress.html @@ -0,0 +1,6 @@ +{% load humanize %} + + +
    + {{ total|intcomma }} +
    diff --git a/templates/admin/index.html b/templates/admin/index.html index 9ff5288..c2f6d1a 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -1,213 +1,24 @@ -{% extends 'admin/base_site.html' %} -{% load static %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -
    -
    Monthly User Chart
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -{% endblock %} - -{% block scripts %} - {% if request.user.is_superuser %} - - - - + {% endif %} +{% endblock %} +{% block branding %} + {% include "unfold/helpers/site_branding.html" %} +{% endblock %} - +{% block content %} + {% include "unfold/helpers/messages.html" %} +{% endblock %} - {% endif %} -{% endblock %} \ No newline at end of file diff --git a/templates/api/documentation.html b/templates/api/documentation.html new file mode 100644 index 0000000..f933cae --- /dev/null +++ b/templates/api/documentation.html @@ -0,0 +1,711 @@ + + + + + + {{ title }} + + + + + + + + + + + + +
    + + + + +
    +
    +
    +
    +

    {{ title }}

    +

    {{ description }}

    +
    + +
    +
    + + + {% for app_key, app_data in api_structure.items %} + {% for endpoint in app_data.endpoints %} +
    +
    +

    {{ endpoint.name }}

    + {{ endpoint.method }} + {{ endpoint.url }} +
    + +

    {{ endpoint.description }}

    + + {% if endpoint.parameters %} +
    +

    + + Parameters +

    + + + + + + + + + + + {% for param in endpoint.parameters %} + + + + + + + {% endfor %} + +
    NameTypeRequiredDescription
    {{ param.name }}{{ param.type }} + {% if param.required %} + Required + {% else %} + Optional + {% endif %} + {{ param.description }}
    +
    + {% endif %} + +
    +

    + + Response Examples +

    + +
    + {% for response_type, response_data in endpoint.response_examples.items %} + + {% endfor %} +
    + + {% for response_type, response_data in endpoint.response_examples.items %} +
    +
    {{ response_data|safe }}
    +
    + {% endfor %} +
    +
    + {% endfor %} + {% endfor %} +
    +
    + + + + + + + + + diff --git a/templates/course/course_analytics.html b/templates/course/course_analytics.html new file mode 100644 index 0000000..4312d75 --- /dev/null +++ b/templates/course/course_analytics.html @@ -0,0 +1,283 @@ +{% load i18n %} +{% load unfold %} + +{% component "unfold/components/container.html" with component_class="CourseAnalyticsComponent" %} + +
    +
    + {% component "unfold/components/title.html" with class="text-2xl font-bold text-gray-800" %} + {% trans "Course Analytics Dashboard" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600" %} + {% trans "Comprehensive analytics and insights for your course" %} + {% endcomponent %} +
    +
    + + +
    +
    + + +
    + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-blue-50 to-blue-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-blue-700 m-0" %} + {{ total_lessons }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-blue-800 mt-1 opacity-80" %} + {% trans "Total Lessons" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-purple-50 to-purple-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-purple-700 m-0" %} + {{ quiz_scores_data.data.datasets.0.data|length }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-purple-800 mt-1 opacity-80" %} + {% trans "Total Quizzes" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-green-50 to-green-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-green-700 m-0" %} + {{ total_participants }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-green-800 mt-1 opacity-80" %} + {% trans "Active Participants" %} + {% endcomponent %} +
    +
    + {% endcomponent %} +
    + + +
    + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-2 text-gray-800" %} + {% trans "Lesson Completion Distribution" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600 mb-6" %} + {% trans "Percentage of course completion by participants" %} + {% endcomponent %} +
    + {% component "unfold/components/chart/bar.html" with data=completion_data.data height=300 %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-2 text-gray-800" %} + {% trans "Quiz Scores Distribution" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600 mb-6" %} + {% trans "Distribution of scores across all quizzes" %} + {% endcomponent %} +
    + {% component "unfold/components/chart/bar.html" with data=quiz_scores_data.data height=300 %} + {% endcomponent %} +
    +
    + {% endcomponent %} +
    + + +
    + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-6 text-gray-800" %} + {% trans "Course Engagement Metrics" %} + {% endcomponent %} + +
    + +
    +
    + {% trans "Lesson Completion Rate" %} + {{ engagement_metrics.lesson_completion_rate }}% +
    + {% component "unfold/components/progress.html" with value=engagement_metrics.lesson_completion_rate class="h-2 bg-blue-200" bar_class="bg-blue-600" %} + {% endcomponent %} +

    {% trans "Average percentage of lessons completed by participants" %}

    +
    + + +
    +
    + {% trans "Quiz Participation Rate" %} + {{ engagement_metrics.quiz_participation_rate }}% +
    + {% component "unfold/components/progress.html" with value=engagement_metrics.quiz_participation_rate class="h-2 bg-purple-200" bar_class="bg-purple-600" %} + {% endcomponent %} +

    {% trans "Percentage of participants who attempted at least one quiz" %}

    +
    + + +
    +
    + {% trans "Average Quiz Score" %} + {{ engagement_metrics.average_quiz_score }}% +
    + {% component "unfold/components/progress.html" with value=engagement_metrics.average_quiz_score class="h-2 bg-green-200" bar_class="bg-green-600" %} + {% endcomponent %} +

    {% trans "Average score across all quizzes" %}

    +
    +
    +
    + {% endcomponent %} +
    + + +
    + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %} + {% trans "Top Performing Students" %} + {% endcomponent %} + +
    + + + + + + + + + + {% for student in top_students %} + + + + + + {% endfor %} + +
    + {% trans "Student" %} + + {% trans "Completion" %} + + {% trans "Avg. Score" %} +
    +
    +
    + {{ student.initials }} +
    +
    +
    {{ student.name }}
    +
    +
    +
    +
    {{ student.completion_percentage }}%
    +
    + + {{ student.average_score }}% + +
    +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %} + {% trans "Recent Activity" %} + {% endcomponent %} + +
    +
      + {% for activity in recent_activity %} +
    • +
      + {% if not forloop.last %} + + {% endif %} +
      +
      +
      + {% if activity.type == 'lesson_completion' %} + + + + {% elif activity.type == 'quiz_completion' %} + + + + {% elif activity.type == 'course_join' %} + + + + {% endif %} +
      +
      +
      +
      + {% if activity.type == 'lesson_completion' %} + {{ activity.student_name }} درس + {{ activity.lesson_title }} را تکمیل کرد + {{ activity.time_ago }} + {% elif activity.type == 'quiz_completion' %} + {{ activity.student_name }} در کوئیز + {{ activity.quiz_title }} نمره {{ activity.score }}% کسب کرد + {{ activity.time_ago }} + {% elif activity.type == 'course_join' %} + {{ activity.student_name }} به دوره پیوست + {{ activity.time_ago }} + {% endif %} +
      +
      +
      +
      +
    • + {% endfor %} +
    +
    +
    + {% endcomponent %} +
    +{% endcomponent %} \ No newline at end of file diff --git a/templates/course/course_stats.html b/templates/course/course_stats.html new file mode 100644 index 0000000..4785253 --- /dev/null +++ b/templates/course/course_stats.html @@ -0,0 +1,161 @@ +{% load i18n %} +{% load unfold %} +{% load course_tags %} + +{% component "unfold/components/container.html" %} + +
    +
    + {% component "unfold/components/title.html" with class="text-2xl font-bold text-gray-800" %} + {% trans "Course Overview" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600" %} + {% trans "Key metrics and statistics for this course" %} + {% endcomponent %} +
    + +
    + + +
    + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-blue-50 to-blue-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-blue-700 m-0" %} + {{ original.lessons.count }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-blue-800 mt-1 opacity-80" %} + {% trans "Total Lessons" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-purple-50 to-purple-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-purple-700 m-0" %} + {% get_course_quizzes_count original as quiz_count %} + {{ quiz_count }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-purple-800 mt-1 opacity-80" %} + {% trans "Total Quizzes" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-green-50 to-green-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-green-700 m-0" %} + {{ original.participants.count }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-green-800 mt-1 opacity-80" %} + {% trans "Active Participants" %} + {% endcomponent %} +
    +
    + {% endcomponent %} +
    + + +
    + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %} + {% trans "Course Summary" %} + {% endcomponent %} + +
    +
    +
    + + + +
    +
    +

    {% trans "Course Status" %}

    +

    {{ original.get_status_display }}

    +
    +
    + +
    +
    + + + +
    +
    +

    {% trans "Duration" %}

    +

    {{ original.duration }} {% trans "hours" %}

    +
    +
    + +
    +
    + + + +
    +
    +

    {% trans "Level" %}

    +

    {{ original.get_level_display }}

    +
    +
    + +
    +
    + + + +
    +
    +

    {% trans "Price" %}

    +

    + {% if original.is_free %} + {% trans "Free" %} + {% else %} + {{ original.final_price }} + {% endif %} +

    +
    +
    +
    +
    + {% endcomponent %} +
    + + +
    + {% include "course/course_analytics.html" %} +
    +{% endcomponent %} \ No newline at end of file diff --git a/templates/docs.html b/templates/docs.html index c5f9142..f1dc6f5 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -64,6 +64,85 @@ + +
    +
    +
    + مستندات API تراکنش‌ها و رسیدهای پرداخت +
    +
    +

    🔹 ثبت‌نام در دوره و ایجاد تراکنش

    +

    Endpoint: POST /api/transactions/<slug>/join/

    +

    این API برای ثبت‌نام کاربر در دوره و ایجاد تراکنش استفاده می‌شود.

    +
      +
    • برای دوره‌های رایگان، تراکنش به صورت خودکار تایید می‌شود
    • +
    • برای دوره‌های پولی، تراکنش با وضعیت pending ایجاد می‌شود
    • +
    +
    + +

    🔹 آپلود رسید پرداخت

    +

    Endpoint: POST /api/transactions/<transaction_id>/receipts/upload/

    +

    برای آپلود رسید پرداخت دوره‌های پولی استفاده می‌شود.

    +
      +
    • حداکثر 10 فایل قابل آپلود در هر درخواست
    • +
    • حداکثر حجم هر فایل: 10 مگابایت
    • +
    • پس از آپلود موفق، وضعیت تراکنش به waiting_approval تغییر می‌کند
    • +
    +
    + +

    🔹 مشاهده رسیدهای یک تراکنش

    +

    Endpoint: GET /api/transactions/<transaction_id>/receipts/

    +

    برای دریافت لیست تمام رسیدهای آپلود شده برای یک تراکنش.

    +
    + +

    🔹 لیست تراکنش‌های کاربر

    +

    Endpoint: GET /api/transactions/list/

    +

    برای دریافت لیست تمام تراکنش‌های کاربر احراز هویت شده.

    +
    + +

    🔹 وضعیت‌های تراکنش

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    وضعیتتوضیحات
    pendingدر انتظار پرداخت - کاربر باید رسید را آپلود کند
    waiting_approvalدر انتظار تایید - رسید آپلود شده و منتظر تایید ادمین
    successپرداخت موفق و تایید شده - کاربر به دوره دسترسی دارد
    failedپرداخت ناموفق یا رد شده
    +
    + +

    📌 نکات مهم برای ادمین

    +
      +
    • زمانی که کاربر رسید آپلود می‌کند، وضعیت تراکنش به waiting_approval تغییر می‌کند
    • +
    • ادمین باید رسیدها را در پنل ادمین بررسی کرده و وضعیت را به success یا failed تغییر دهد
    • +
    • زمانی که وضعیت به success تغییر کند، کاربر به صورت خودکار به عنوان دانشجو در دوره ثبت می‌شود
    • +
    • تمام رسیدهای آپلود شده در پنل ادمین قابل مشاهده هستند
    • +
    + +

    + مشاهده مستندات کامل Swagger +

    +
    +
    +
    {% endblock %} \ No newline at end of file diff --git a/templates/swagger/auth.html b/templates/swagger/auth.html new file mode 100644 index 0000000..a87d382 --- /dev/null +++ b/templates/swagger/auth.html @@ -0,0 +1,402 @@ + + + + + + Swagger Authentication - Imam Javad API + + + + + + + + +
    +
    +
    +

    API Authentication

    +

    Enter your API token to access Swagger UI

    +
    + +
    + + {% if messages %} + {% for message in messages %} +
    + {{ message }} +
    + {% endfor %} + {% endif %} + + + {% if user_info %} + + {% endif %} + + +
    + {% csrf_token %} +
    + + + + Token must be exactly 40 characters long + +
    + + +
    + + +
    +
    + + How to get your API token? +
    +

    + Your API token can be found in your user profile or generated through the Django admin panel. + Contact your system administrator if you need assistance obtaining your token. +

    +
    + + + +
    +
    +
    + + + + + + + diff --git a/templates/swagger/ui.html b/templates/swagger/ui.html new file mode 100644 index 0000000..f14d931 --- /dev/null +++ b/templates/swagger/ui.html @@ -0,0 +1,363 @@ +{% load static %} + + + + + + Imam Javad API - Swagger UI + {% csrf_token %} + + + + + + + + +
    +
    +
    + {% if request.session.swagger_user_info %} + +
    +
    + Authenticated +
    + {% else %} +
    +
    + Not Authenticated +
    + {% endif %} +
    + +
    + + + Documentation + + {% if request.session.swagger_user_info %} + + + Logout + + {% else %} + + + Authenticate + + {% endif %} +
    +
    +
    + + +
    + + + + + + + + + diff --git a/templates/utils/widgets/multilang_json_widget.html b/templates/utils/widgets/multilang_json_widget.html new file mode 100644 index 0000000..584ab37 --- /dev/null +++ b/templates/utils/widgets/multilang_json_widget.html @@ -0,0 +1,280 @@ +{% load i18n %} +
    +
    +
    +
    + {% for code in widget.languages %} + + {% endfor %} +
    +
    +
    + +
    + {% for input in widget.inputs %} + + {% endfor %} + +
    + +
    + {% trans "Click a language code to add or edit its title." %} +
    +
    + + + + + + + + + + + + diff --git a/test_apis.py b/test_apis.py new file mode 100644 index 0000000..43d72fb --- /dev/null +++ b/test_apis.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Test script to verify all video and podcast API endpoints +""" + +import os +import django +import sys + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from django.test import RequestFactory +from django.contrib.auth import get_user_model +from apps.video.views import ( + VideoCategoryListAPIView, + PinnedVideoCollectionListView, + MiddleVideoCollectionListView, + VideoPlaylistListAPIView, + VideoPlaylistDetailAPIView, +) +from apps.podcast.views import ( + PodcastCategoryListAPIView, + PinnedPodcastCollectionListView, + MiddlePodcastCollectionListView, + PodcastListAPIView, + PodcastDetailAPIView, +) +from apps.video.models import VideoPlaylist +from apps.podcast.models import Podcast + +User = get_user_model() + +# Create a request factory +factory = RequestFactory() + +def test_endpoint(view_class, url, method='GET', user=None, slug=None): + """Test a single endpoint""" + try: + request = factory.get(url) + if user: + request.user = user + else: + # Create an anonymous user + from django.contrib.auth.models import AnonymousUser + request.user = AnonymousUser() + + view = view_class.as_view() + if slug: + response = view(request, slug=slug) + else: + response = view(request) + + status_code = response.status_code + return { + 'status': 'SUCCESS' if status_code == 200 else 'FAILED', + 'status_code': status_code, + 'error': None + } + except Exception as e: + return { + 'status': 'ERROR', + 'status_code': None, + 'error': str(e) + } + +def main(): + print("=" * 80) + print("TESTING VIDEO AND PODCAST APIs") + print("=" * 80) + + # Get or create a test user + try: + user = User.objects.first() + if not user: + print("\n⚠️ No users found in database. Testing with anonymous user only.\n") + except Exception as e: + print(f"\n⚠️ Error getting user: {e}. Testing with anonymous user only.\n") + user = None + + # VIDEO API TESTS + print("\n" + "=" * 80) + print("VIDEO APIs") + print("=" * 80) + + video_tests = [ + { + 'name': 'Video Categories List', + 'view': VideoCategoryListAPIView, + 'url': '/api/videos/categories/', + 'user': None + }, + { + 'name': 'Pinned Video Collections List', + 'view': PinnedVideoCollectionListView, + 'url': '/api/videos/pinned-collections/', + 'user': user + }, + { + 'name': 'Middle Video Collections List', + 'view': MiddleVideoCollectionListView, + 'url': '/api/videos/collections/', + 'user': user + }, + { + 'name': 'Video Playlists List', + 'view': VideoPlaylistListAPIView, + 'url': '/api/videos/playlists/', + 'user': None + }, + ] + + # Test detail endpoint if we have a playlist + try: + first_playlist = VideoPlaylist.objects.filter(status=True).first() + if first_playlist: + video_tests.append({ + 'name': f'Video Playlist Detail (slug: {first_playlist.slug})', + 'view': VideoPlaylistDetailAPIView, + 'url': f'/api/videos/playlists/{first_playlist.slug}/', + 'user': None, + 'slug': first_playlist.slug + }) + except Exception as e: + print(f"\n⚠️ Could not fetch video playlist for detail test: {e}") + + for test in video_tests: + result = test_endpoint( + test['view'], + test['url'], + user=test.get('user'), + slug=test.get('slug') + ) + status_symbol = "✅" if result['status'] == 'SUCCESS' else "❌" + print(f"\n{status_symbol} {test['name']}") + print(f" URL: {test['url']}") + print(f" Status: {result['status']} (HTTP {result['status_code']})") + if result['error']: + print(f" Error: {result['error']}") + + # PODCAST API TESTS + print("\n" + "=" * 80) + print("PODCAST APIs") + print("=" * 80) + + podcast_tests = [ + { + 'name': 'Podcast Categories List', + 'view': PodcastCategoryListAPIView, + 'url': '/api/podcast/categories/', + 'user': None + }, + { + 'name': 'Pinned Podcast Collections List', + 'view': PinnedPodcastCollectionListView, + 'url': '/api/podcast/pinned-collections/', + 'user': user + }, + { + 'name': 'Middle Podcast Collections List', + 'view': MiddlePodcastCollectionListView, + 'url': '/api/podcast/collections/', + 'user': user + }, + { + 'name': 'Podcasts List', + 'view': PodcastListAPIView, + 'url': '/api/podcast/list/', + 'user': None + }, + ] + + # Test detail endpoint if we have a podcast + try: + first_podcast = Podcast.objects.filter(status=True).first() + if first_podcast: + podcast_tests.append({ + 'name': f'Podcast Detail (slug: {first_podcast.slug})', + 'view': PodcastDetailAPIView, + 'url': f'/api/podcast/detail/{first_podcast.slug}/', + 'user': None, + 'slug': first_podcast.slug + }) + except Exception as e: + print(f"\n⚠️ Could not fetch podcast for detail test: {e}") + + for test in podcast_tests: + result = test_endpoint( + test['view'], + test['url'], + user=test.get('user'), + slug=test.get('slug') + ) + status_symbol = "✅" if result['status'] == 'SUCCESS' else "❌" + print(f"\n{status_symbol} {test['name']}") + print(f" URL: {test['url']}") + print(f" Status: {result['status']} (HTTP {result['status_code']})") + if result['error']: + print(f" Error: {result['error']}") + + print("\n" + "=" * 80) + print("TEST COMPLETE") + print("=" * 80 + "\n") + +if __name__ == '__main__': + main() diff --git a/test_hadis_urls.py b/test_hadis_urls.py new file mode 100644 index 0000000..5cf0e5b --- /dev/null +++ b/test_hadis_urls.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python +""" +Simple script to test hadis API endpoints connectivity. +This script manually tests all hadis URLs to verify they return proper HTTP status codes. +""" + +import os +import sys +import django +from django.conf import settings +from django.test import override_settings + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') +django.setup() + +from django.test import Client +from django.urls import reverse +from rest_framework import status + + +def test_endpoint(client, url_name, kwargs=None, description=""): + """Test a single endpoint and return the result""" + try: + if kwargs: + url = reverse(url_name, kwargs=kwargs) + else: + url = reverse(url_name) + + response = client.get(url) + status_code = response.status_code + + if status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]: + result = "✅ PASS" + else: + result = f"❌ FAIL ({status_code})" + + print(f"{result} {description}: {url} -> {status_code}") + return status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] + + except Exception as e: + print(f"❌ ERROR {description}: {url_name} -> {str(e)}") + return False + + +def main(): + """Main test function""" + print("🧪 Testing Hadis API Endpoints Connectivity") + print("=" * 60) + + # Override ALLOWED_HOSTS for testing + with override_settings(ALLOWED_HOSTS=['testserver']): + client = Client() + total_tests = 0 + passed_tests = 0 + + # Test non-parameterized endpoints + print("\n📋 Testing non-parameterized endpoints:") + non_param_endpoints = [ + ('hadis-collection-list', None, 'Collections'), + ('hadis-sect-list', None, 'Sync Sects'), + ('hadis-category-tree', None, 'Sync Categories Tree'), + ('hadis-sync', None, 'Sync Hadis'), + ('transmitter-sync', None, 'Sync Narrators'), + ('reference-sync', None, 'Sync References'), + ('hadis-info', None, 'Info'), + ('hadis-category-tree-normal', None, 'Categories Tree Normal'), + ('categories', None, 'Categories'), + ('hadis-main-list', None, 'Hadis Main List (Arguments)'), + ('hadis-filters', None, 'Hadis Filters'), + ('narrator-filters', None, 'Narrator Filters'), + ('narrators', None, 'Narrators'), + ('references', None, 'References'), + ] + + for url_name, kwargs, description in non_param_endpoints: + total_tests += 1 + if test_endpoint(client, url_name, kwargs, description): + passed_tests += 1 + + # Test parameterized endpoints + print("\n📋 Testing parameterized endpoints:") + param_endpoints = [ + ('categories-by-sect', {'sect_type': '1'}, 'Categories by Sect (type=1)'), + ('categories-tree-by-sect', {'sect_type': '1', 'slug': 'test-category'}, 'Categories Tree by Sect'), + ('categories-tree-by-sect-source', {'sect_type': '1', 'slug': 'test-category', 'source_type': 'quran'}, 'Categories Tree by Sect Source'), + ('hadis-list', {'category_slug': 'test-category'}, 'Hadis List by Category'), + ('narrator-detail', {'narrator_slug': 'test-narrator'}, 'Narrator Detail'), + ('narrator-opinions', {'narrator_slug': 'test-narrator'}, 'Narrator Opinions'), + ('narrator-original-texts', {'narrator_slug': 'test-narrator'}, 'Narrator Original Texts'), + ('reference-detail', {'reference_slug': 'test-reference'}, 'Reference Detail'), + ('hadis-basic', {'hadis_slug': 'test-hadis'}, 'Hadis Basic'), + ('hadis-detail', {'hadis_slug': 'test-hadis'}, 'Hadis Detail'), + ('hadis-transmitters', {'hadis_slug': 'test-hadis'}, 'Hadis Transmitters'), + ('hadis-corrections', {'hadis_slug': 'test-hadis'}, 'Hadis Corrections'), + ] + + for url_name, kwargs, description in param_endpoints: + total_tests += 1 + if test_endpoint(client, url_name, kwargs, description): + passed_tests += 1 + + # Summary + print("\n" + "=" * 60) + print(f"📊 Test Results: {passed_tests}/{total_tests} endpoints accessible") + success_rate = (passed_tests / total_tests) * 100 if total_tests > 0 else 0 + print(f"📈 Success Rate: {success_rate:.1f}%") + if passed_tests == total_tests: + print("✅ All endpoints are properly configured and accessible!") + else: + print("⚠️ Some endpoints may have issues") + + return passed_tests == total_tests + + +if __name__ == '__main__': + success = main() + sys.exit(0 if success else 1) diff --git a/test_serializer.py b/test_serializer.py new file mode 100644 index 0000000..80d7d13 --- /dev/null +++ b/test_serializer.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +import os +import sys +import django +from pathlib import Path + +# Set up Django environment manually +sys.path.insert(0, str(Path(__file__).parent)) + +try: + # Try to avoid the environ import issue + import importlib + sys.modules['environ'] = importlib.util.spec_from_loader('environ', None) + + from django.conf import settings + if not settings.configured: + settings.configure( + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'imam_javad_db', + 'USER': 'postgres', + 'PASSWORD': '123456789', + 'HOST': 'localhost', + 'PORT': '5432', + } + }, + INSTALLED_APPS=[ + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'apps.hadis', + ], + USE_TZ=True, + SECRET_KEY='temp-key-for-test', + ) + + django.setup() + + from apps.hadis.models import Transmitters + from apps.hadis.serializers.hadis import TransmitterDetailSerializer + + transmitter = Transmitters.objects.first() + if transmitter: + serializer = TransmitterDetailSerializer(transmitter) + data = serializer.data + print('✓ Serializer works!') + print(f'Has opinions field: {"opinions" in data}') + print(f'Has hadis_transmissions field: {"hadis_transmissions" in data}') + if 'opinions' in data: + print(f'Opinions count: {len(data["opinions"])}') + if 'hadis_transmissions' in data: + print(f'Hadis transmissions count: {len(data["hadis_transmissions"])}') + print('Test completed successfully!') + else: + print('No transmitters found') + +except Exception as e: + print(f'✗ Error: {e}') + import traceback + traceback.print_exc() diff --git a/utils/__init__.py b/utils/__init__.py index 9de0b13..dc91cb3 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -5,6 +5,14 @@ import mimetypes import re from urllib.parse import urlparse +<<<<<<< HEAD +======= +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile + +from pathlib import Path +from django.utils.text import get_valid_filename +>>>>>>> develop from django.conf import settings from django.core.files import File from django.http import HttpRequest @@ -19,7 +27,64 @@ from django.utils.text import slugify import random import string +<<<<<<< HEAD +======= +from django.conf import settings + +from django.utils.translation import gettext_lazy as _ + +from cachetools.func import lru_cache +from django.http import HttpRequest +from django.contrib import admin + +# Moved filer imports to avoid circular imports +# These will be imported when needed in functions + +@lru_cache +def qs_thumbs(): + from filer.models import ThumbnailOption + return ThumbnailOption.objects.all() + + +def get_thumbs(obj, request: HttpRequest = None) -> dict: + # print(f'----> {obj}') + if not obj: + return {} + + try: + # تعریف سه سایز ثابت + sizes = ['sm', 'md', 'lg'] + thumbnail_object = {} + + # گرفتن URL اصلی تصویر + if hasattr(obj, 'url'): + original_url = obj.url + else: + return {} + + # برای هر سه سایز، همان URL اصلی را برگردان + for size in sizes: + if request: + url = request.build_absolute_uri(original_url) + else: + url = original_url + + thumbnail_object[size] = url + + return thumbnail_object + + except Exception as p: + print(p) + return {} + + +def environment_callback(request): + if settings.DEBUG: + return [_("Development"), "primary"] + + return [_("Production"), "primary"] +>>>>>>> develop @@ -105,6 +170,33 @@ def generate_slug_for_model(model, value: str, recycled_count: int = 0): return slug[:50] +<<<<<<< HEAD +======= + +def generate_language_slugs(translations): + """ + Build a list of {language_code, title} where title is a slugified string + from provided multilingual translations list. + Expected input shape: + - list[dict]: [{'language_code': 'fa', 'title': 'متن'}, ...] + Fallback keys supported: code/lang/language for language, value/text for content. + """ + try: + result = [] + if isinstance(translations, list): + for tr in translations: + if isinstance(tr, dict): + language_code = tr.get('language_code') or tr.get('code') or tr.get('lang') or tr.get('language') + text = tr.get('title') or tr.get('text') or tr.get('value') + if language_code and text: + slug_text = slugify(text, allow_unicode=True) + result.append({'language_code': str(language_code), 'title': slug_text}) + return result + except Exception as e: + print(f"Error generating slugs: {e}") + return [] + +>>>>>>> develop def absolute_url(req, url): """ can either be a file instance or a URL string @@ -121,6 +213,29 @@ def sizeof_fmt(num, suffix="B"): num /= 1024.0 return f"{num:.1f} Yi{suffix}" +<<<<<<< HEAD +======= +def file_location_media(path: str): + """ + Resolve a media URL/relative path to absolute filesystem path under MEDIA_ROOT. + """ + from django.conf import settings + + media_url = (getattr(settings, "MEDIA_URL", "/media/") or "/media/").rstrip("/") + media_root = settings.MEDIA_ROOT + + if path.startswith("http"): + path = exclude_host_from_url(path) + + if path.startswith(media_url + "/"): + path = path[len(media_url):] + + if path.startswith("/"): + path = path[1:] + + return os.path.join(media_root, path) + +>>>>>>> develop def file_location(path): from django.conf import settings @@ -176,6 +291,7 @@ class FileFieldSerializer(serializers.CharField): # value not changed and here we simply return old file path return self.get_rpath(data) +<<<<<<< HEAD if data.startswith('http'): data = self.get_rpath(data) @@ -184,6 +300,170 @@ class FileFieldSerializer(serializers.CharField): raise serializers.ValidationError(f"File: '{fpath}' Does not exist") return File(open(fpath, 'rb'), os.path.basename(data)) +======= + # if data.startswith('http'): + # data = self.get_rpath(data) + + fpath = file_location_media(data) + if not os.path.exists(fpath): + raise serializers.ValidationError(f"File: '{fpath}' Does not exist") + + rel = os.path.basename(data) + return File(open(fpath, "rb"), rel) + # return File(open(fpath, 'rb'), os.path.basename(data)) + + +class UploadChatMediaSerializer(serializers.Serializer): + """Upload files permanently to /media/chat/""" + file = serializers.FileField() + url = serializers.URLField(read_only=True) + name = serializers.CharField(read_only=True) + size = serializers.CharField(read_only=True) + mime_type = serializers.CharField(read_only=True) + thumbnail_url = serializers.URLField(read_only=True, required=False) + + def to_representation(self, instance): + data = super(UploadChatMediaSerializer, self).to_representation(instance) + data['file'] = instance['file'] + return data + + # def store_file(self, file): + # from django.conf import settings + # from utils.image_utils import ( + # create_thumbnail, + # is_image_file, + # is_video_file, + # extract_video_thumbnail + # ) + # media_path = settings.MEDIA_ROOT + + # os.makedirs(f'{media_path}/chat/uploads', exist_ok=True) + # fpath = f"/chat/uploads/{secrets.token_urlsafe(4)}-{file.name}" + # full_path = media_path + fpath + # if hasattr(file, 'read'): + # default_storage.save(str(full_path), ContentFile(file.read())) + # else: + # default_storage.save(str(full_path), file) + # os.chmod(full_path, 0o644) + + # result = { + # 'file': fpath, + # 'url': absolute_url(self.context['request'], f"/media{fpath}"), + # 'name': file.name, + # 'size': sizeof_fmt(file.size), + # 'mime_type': guess_file_type(fpath) + # } + + # # Generate thumbnail if file is an image (low quality for preview) + # if is_image_file(full_path): + # try: + # thumbnail_path = create_thumbnail(full_path, size=(200, 200), quality=60) + # thumbnail_relative = thumbnail_path.replace(media_path, '') + # result['thumbnail_url'] = absolute_url( + # self.context['request'], + # f"/media{thumbnail_relative}" + # ) + # except Exception as e: + # print(f"Failed to generate image thumbnail: {e}") + # result['thumbnail_url'] = None + # # Generate thumbnail if file is a video (low quality for preview) + # elif is_video_file(full_path): + # try: + # thumbnail_path = extract_video_thumbnail( + # full_path, + # time_offset='00:00:01', + # size=(200, 200), + # quality=60 + # ) + # thumbnail_relative = thumbnail_path.replace(media_path, '') + # result['thumbnail_url'] = absolute_url( + # self.context['request'], + # f"/media{thumbnail_relative}" + # ) + # except Exception as e: + # print(f"Failed to generate video thumbnail: {e}") + # result['thumbnail_url'] = None + # else: + # result['thumbnail_url'] = None + + # return result + def store_file(self, file): + from django.conf import settings + from utils.image_utils import ( + create_thumbnail, + is_image_file, + is_video_file, + extract_video_thumbnail, + ) + + media_root = Path(settings.MEDIA_ROOT) # Path object + chat_dir = media_root / "chat" / "uploads" + chat_dir.mkdir(parents=True, exist_ok=True) + + safe_name = get_valid_filename(os.path.basename(file.name)) + rel_path = Path("chat") / "uploads" / f"{secrets.token_urlsafe(4)}-{safe_name}" + full_path = media_root / rel_path # Path object + + # Save via default_storage (path must be relative to MEDIA_ROOT) + if hasattr(file, "read"): + default_storage.save(str(rel_path), ContentFile(file.read())) + else: + default_storage.save(str(rel_path), file) + + # Optional: chmod only if local filesystem and OS supports it + try: + os.chmod(full_path, 0o644) + except PermissionError: + pass + + result = { + "file": f"/{rel_path.as_posix()}", + "url": absolute_url( + self.context["request"], + f"{settings.MEDIA_URL.rstrip('/')}/{rel_path.as_posix()}", + ), + "name": safe_name, + "size": sizeof_fmt(file.size), + "mime_type": guess_file_type(rel_path.name), + } + + # For images + if is_image_file(str(full_path)): + try: + thumbnail_path = create_thumbnail(str(full_path), size=(200, 200), quality=60) + thumb_rel = str(thumbnail_path).replace(str(media_root), "").lstrip("\\/") + result["thumbnail_url"] = absolute_url( + self.context["request"], + f"{settings.MEDIA_URL.rstrip('/')}/{thumb_rel.replace(os.sep, '/')}", + ) + except Exception as e: + print(f"Failed to generate image thumbnail: {e}") + result["thumbnail_url"] = None + # For videos + elif is_video_file(str(full_path)): + try: + thumbnail_path = extract_video_thumbnail( + str(full_path), + time_offset="00:00:01", + size=(200, 200), + quality=60, + ) + thumb_rel = str(thumbnail_path).replace(str(media_root), "").lstrip("\\/") + result["thumbnail_url"] = absolute_url( + self.context["request"], + f"{settings.MEDIA_URL.rstrip('/')}/{thumb_rel.replace(os.sep, '/')}", + ) + except Exception as e: + print(f"Failed to generate video thumbnail: {e}") + result["thumbnail_url"] = None + else: + result["thumbnail_url"] = None + return result + + def validate(self, attrs): + file_details = self.store_file(attrs['file']) + return file_details +>>>>>>> develop class UploadTmpSerializer(serializers.Serializer): @@ -192,6 +472,10 @@ class UploadTmpSerializer(serializers.Serializer): name = serializers.CharField(read_only=True) size = serializers.CharField(read_only=True) mime_type = serializers.CharField(read_only=True) +<<<<<<< HEAD +======= + thumbnail_url = serializers.URLField(read_only=True, required=False) +>>>>>>> develop def to_representation(self, instance): data = super(UploadTmpSerializer, self).to_representation(instance) @@ -200,6 +484,7 @@ class UploadTmpSerializer(serializers.Serializer): def store_file(self, file): from django.conf import settings +<<<<<<< HEAD static_path = settings.STATIC_ROOT os.makedirs(f'{static_path}/tmp', exist_ok=True) @@ -215,14 +500,108 @@ class UploadTmpSerializer(serializers.Serializer): 'mime_type': guess_file_type(fpath) } +======= + from utils.image_utils import ( + create_thumbnail, + is_image_file, + is_video_file, + extract_video_thumbnail, + ) + + media_root = Path(settings.MEDIA_ROOT) + tmp_dir = media_root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + safe_name = get_valid_filename(os.path.basename(file.name)) + rel_path = Path("tmp") / f"{secrets.token_urlsafe(4)}-{safe_name}" + + # Save the file using Django storage (safe on Windows) + # If the file is an InMemoryFile or TemporaryUploadedFile, read its content + if hasattr(file, 'read'): + default_storage.save(str(rel_path), ContentFile(file.read())) + else: + default_storage.save(str(rel_path), file) + + full_path = str(media_root / rel_path) + + result = { + "file": f"/{rel_path.as_posix()}", + "url": absolute_url( + self.context["request"], + f"{settings.MEDIA_URL.rstrip('/')}/{rel_path.as_posix()}", + ), + "name": safe_name, + "size": sizeof_fmt(file.size), + "mime_type": guess_file_type(rel_path.name), + } + + # Generate thumbnail if image + if is_image_file(full_path): + try: + thumb_path = create_thumbnail(full_path, size=(200, 200), quality=60) + thumb_rel = thumb_path.replace(str(media_root), "").lstrip("\\/") + result["thumbnail_url"] = absolute_url( + self.context["request"], + f"{settings.MEDIA_URL.rstrip('/')}/{thumb_rel.replace(os.sep, '/')}", + ) + except Exception as e: + print("Failed to generate image thumbnail:", e) + result["thumbnail_url"] = None + # Generate thumbnail if video + elif is_video_file(full_path): + try: + thumb_path = extract_video_thumbnail( + full_path, + time_offset="00:00:01", + size=(200, 200), + quality=60, + ) + thumb_rel = thumb_path.replace(str(media_root), "").lstrip("\\/") + result["thumbnail_url"] = absolute_url( + self.context["request"], + f"{settings.MEDIA_URL.rstrip('/')}/{thumb_rel.replace(os.sep, '/')}", + ) + except Exception as e: + print("Failed to generate video thumbnail:", e) + result["thumbnail_url"] = None + else: + result["thumbnail_url"] = None + + return result + +>>>>>>> develop def validate(self, attrs): file_details = self.store_file(attrs['file']) return file_details +<<<<<<< HEAD class UploadTmpMedia(GenericAPIView): """ Files will remove every 1 hour +======= +class UploadChatMedia(GenericAPIView): + """ + Upload files permanently to /media/chat/ + Files are stored permanently and will NOT be removed + """ + parser_classes = (FormParser, MultiPartParser) + serializer_class = UploadChatMediaSerializer + + def post(self, request: HttpRequest, *args, **kwargs): + serializer = UploadChatMediaSerializer(data=request.FILES, context={'request': request}) + is_valid = serializer.is_valid(raise_exception=True) + if not is_valid: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + return Response(serializer.data) + + +class UploadTmpMedia(GenericAPIView): + """ + Upload files temporarily to /static/tmp/ + Files will be removed every 1 hour +>>>>>>> develop """ parser_classes = (FormParser, MultiPartParser) serializer_class = UploadTmpSerializer @@ -234,3 +613,19 @@ class UploadTmpMedia(GenericAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.data) +<<<<<<< HEAD +======= + +# Configure filer admin after Django is fully loaded +def configure_filer_admin(): + try: + from filer.admin.fileadmin import FileAdmin + from filer.apps import FilerConfig + + FileAdmin.readonly_fields += ('owner',) + FilerConfig.icon = 'icon-folder' + except ImportError: + pass + +# This will be executed when this module is imported after Django is fully loaded +>>>>>>> develop diff --git a/utils/admin.py b/utils/admin.py new file mode 100644 index 0000000..4e54939 --- /dev/null +++ b/utils/admin.py @@ -0,0 +1,346 @@ +import json +import random +from functools import lru_cache + +from django import forms +from django.conf import settings +from django.contrib.humanize.templatetags.humanize import intcomma +from django.urls import reverse +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from django.views.generic import RedirectView +from django.utils.translation import get_language + +# Unfold Imports +from unfold.sites import UnfoldAdminSite + +# --------------------------------------------------------- +# 1. Helper Functions +# --------------------------------------------------------- + +def is_dovoodi_panel(request): + """ + Returns True if the user is accessing the Dovoodi admin panel. + Checks if '/dovoodi/' exists anywhere in the path to handle i18n prefixes + (e.g., /en/dovoodi/admin, /fa/dovoodi/admin). + """ + return '/dovoodi/' in request.path + +def is_main_panel(request): + """Returns True if the user is accessing the Main (Imam Javad) admin panel.""" + return not is_dovoodi_panel(request) + +def admin_url_generator(request, url_name): + """ + Dynamically generates admin URLs based on the current active panel. + Usage in settings.py: lambda request: admin_url_generator(request, "app_model_changelist") + """ + # 1. Determine the current namespace using the robust check + if is_dovoodi_panel(request): + namespace = 'dovoodi_admin' + else: + # Default to the main admin + namespace = 'imam_javad_admin' + + # 2. Construct the view name + full_view_name = f"{namespace}:{url_name}" + + # 3. Resolve the URL + try: + return reverse(full_view_name) + except Exception: + return "#" + +def dashboard_callback(request, context): + context.update(random_data()) + return context + +def variables(request): + return {"plausible_domain": getattr(settings, 'PLAUSIBLE_DOMAIN', '')} + +# --------------------------------------------------------- +# 2. Custom Login Form +# --------------------------------------------------------- + +class LoginForm: + """Lazy login form to avoid circular imports during settings loading""" + + @staticmethod + def get_form(): + # Import AuthenticationForm only when needed + from unfold.forms import AuthenticationForm + + class CustomLoginForm(AuthenticationForm): + password = forms.CharField(widget=forms.PasswordInput(render_value=True)) + + def __init__(self, request=None, *args, **kwargs): + super().__init__(request, *args, **kwargs) + # Change the label of the username field to "Email" + self.fields["username"].label = "Email" + + return CustomLoginForm + +# --------------------------------------------------------- +# 3. Admin Site Definitions +# --------------------------------------------------------- + +class FormulaAdminSite(UnfoldAdminSite): + """Main Admin for Imam Jawad""" + site_header = "Imam Jawad Admin" + site_title = "Imam Jawad Admin" + index_title = "System Administration" + site_subheader = "Imam Jawad School" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set login form after initialization to avoid circular import + self.login_form = LoginForm.get_form() + + def _get_colors(self, key, *args): + """Override colors for Imam Javad admin panel with green theme""" + from unfold.utils import hex_to_rgb + + if key != "COLORS": + return super()._get_colors(key, *args) + + # پالت رنگی سبز برای امام جواد + imam_javad_colors = { + "base": { + "50": "249 250 251", + "100": "243 244 246", + "200": "229 231 235", + "300": "209 213 219", + "400": "156 163 175", + "500": "107 114 128", + "600": "75 85 99", + "700": "55 65 81", + "800": "31 41 55", + "900": "17 24 39", + "950": "3 7 18", + }, + "primary": { + "50": "234 253 243", + "100": "208 251 232", + "200": "167 247 216", + "300": "110 240 189", + "400": "37 213 152", + "500": "37 208 118", # #25D076 - سبز اصلی + "600": "29 166 94", + "700": "25 136 80", + "800": "22 108 66", + "900": "20 89 57", + "950": "10 53 34", + }, + "secondary": { + "50": "240 253 250", + "100": "204 251 241", + "200": "153 246 228", + "300": "94 234 212", + "400": "45 212 191", + "500": "1 53 59", # #01353B - پس‌زمینه تیره + "600": "1 43 48", + "700": "1 36 40", + "800": "1 30 34", + "900": "0 26 29", + "950": "0 13 15", + }, + "font": { + "subtle-light": "var(--color-base-500)", + "subtle-dark": "var(--color-base-400)", + "default-light": "var(--color-secondary-500)", + "default-dark": "var(--color-base-300)", + "important-light": "var(--color-base-900)", + "important-dark": "255 255 255", + }, + } + + return imam_javad_colors + +class DovoodiAdminSite(UnfoldAdminSite): + """Secondary Admin for Dovoodi""" + site_header = "Dovoodi Admin" + site_title = "Dovoodi Admin" + index_title = "System Administration" + site_subheader = "Dovodbi Application" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set login form after initialization to avoid circular import + self.login_form = LoginForm.get_form() + + def _get_colors(self, key, *args): + """Override colors for Dovoodi admin panel with blue/teal theme matching frontend""" + from unfold.utils import hex_to_rgb + + if key != "COLORS": + return super()._get_colors(key, *args) + + # پالت رنگی آبی-تیره برای داوودی (مطابق با فرانت) + dovoodi_colors = { + "base": { + # استفاده از Wormy scale برای base + "50": "252 251 250", # #FCFBFA + "100": "246 245 244", # #F6F5F4 + "200": "240 236 233", # #F0ECE9 + "300": "229 220 211", # #E5DCD3 + "400": "191 174 157", # #BFAE9D + "500": "107 114 128", + "600": "75 85 99", + "700": "55 65 81", + "800": "31 41 55", + "900": "17 24 39", # #111827 + "950": "3 7 18", + }, + "primary": { + # استفاده از رنگ آبی اصلی فرانت + "50": "240 244 255", # #F0F4FF + "100": "224 231 255", # #E0E7FF + "200": "199 210 254", # #C7D2FE + "300": "165 180 252", # #A5B4FC + "400": "129 140 248", # #818CF8 + "500": "99 102 241", # #6366F1 + "600": "81 114 225", # #5172E1 - رنگ اصلی فرانت + "700": "59 89 196", # #3B59C4 + "800": "45 68 145", # #2D4491 + "900": "30 41 91", + "950": "15 20 45", + }, + "secondary": { + # استفاده از Second scale فرانت (تیره سبز-آبی) + "50": "210 215 215", # #D2D7D7 + "100": "151 163 164", # #97A3A4 + "200": "108 125 127", # #6C7D7F + "300": "44 69 72", # #2C4548 + "400": "1 31 34", # #011F22 + "500": "1 22 24", # #011618 - پس‌زمینه اصلی + "600": "1 19 21", # #011315 + "700": "0 15 17", + "800": "0 12 14", + "900": "0 8 10", + "950": "0 4 5", + }, + "font": { + "subtle-light": "var(--color-base-500)", + "subtle-dark": "var(--color-base-400)", + "default-light": "var(--color-secondary-400)", + "default-dark": "var(--color-base-200)", + "important-light": "var(--color-base-900)", + "important-dark": "255 255 255", + }, + } + + return dovoodi_colors + +# Simple admin site placeholders that will be replaced after Django setup +class AdminSitePlaceholder(UnfoldAdminSite): + """Placeholder that behaves like an admin site until Django is fully loaded""" + + def __init__(self, site_class, name): + # 1. Store config for lazy loading + self._site_class = site_class + self._name = name + self._real_instance = None + + # 2. THE FIX: Copy visual attributes immediately so Templates see them! + self.site_header = getattr(site_class, 'site_header', 'Django Admin') + self.site_title = getattr(site_class, 'site_title', 'Django Site') + self.index_title = getattr(site_class, 'index_title', 'Site Administration') + self.site_subheader = getattr(site_class, 'site_subheader', '') + + def _get_real_instance(self): + if self._real_instance is None: + # Force creation of real admin site instance for proper CSS loading + self._real_instance = self._site_class(name=self._name) + # Copy critical attributes immediately for template access + self.login_form = self._real_instance.login_form + self.login_template = self._real_instance.login_template + # Copy any other attributes that templates might need + for attr in ['site_header', 'site_title', 'index_title', 'site_subheader']: + if hasattr(self._real_instance, attr): + setattr(self, attr, getattr(self._real_instance, attr)) + return self._real_instance + + def __getattr__(self, name): + # Delegate all attribute access to the real instance for proper CSS and template loading + return getattr(self._get_real_instance(), name) + + def __call__(self, *args, **kwargs): + return self._get_real_instance()(*args, **kwargs) + + def get_urls(self): + return self._get_real_instance().get_urls() + + @property + def urls(self): + return self._get_real_instance().urls + + def each_context(self, request): + return self._get_real_instance().each_context(request) + +# Create placeholder instances that will be replaced with real instances when Django is ready +project_admin_site = AdminSitePlaceholder(FormulaAdminSite, 'imam_javad_admin') +dovoodi_admin_site = AdminSitePlaceholder(DovoodiAdminSite, 'dovoodi_admin') + +# Function to replace placeholders with real instances when Django is ready +def replace_placeholders_with_real_sites(): + global project_admin_site, dovoodi_admin_site + if isinstance(project_admin_site, AdminSitePlaceholder): + project_admin_site = FormulaAdminSite(name='imam_javad_admin') + if isinstance(dovoodi_admin_site, AdminSitePlaceholder): + dovoodi_admin_site = DovoodiAdminSite(name='dovoodi_admin') + +# The placeholders will be replaced with real instances when first accessed +# This ensures proper CSS loading for admin templates + +class HomeView(RedirectView): + def get_redirect_url(self, *args, **kwargs): + host = self.request.get_host() + + # دریافت زبان فعلی (پیش‌فرض: en) + language = get_language() or 'en' + + # دامنه‌های داوودی + dovoodi_domains = ['dovodi.newhorizonco.uk', 'dovoodi.newhorizonco.uk'] + + # تصمیم‌گیری بر اساس دامنه و برگرداندن URL با prefix زبانی + if any(domain in host for domain in dovoodi_domains): + return f'/{language}/dovoodi/admin/' + else: + return f'/{language}/imam-javad/admin/' + +# --------------------------------------------------------- +# 4. Dummy Data for Dashboard Charts +# --------------------------------------------------------- + +@lru_cache +def random_data(): + WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + # Generate some fake data + positive = [[1, random.randrange(8, 28)] for i in range(1, 28)] + negative = [[-1, -random.randrange(8, 28)] for i in range(1, 28)] + average = [r[1] - random.randint(3, 5) for r in positive] + performance_positive = [[1, random.randrange(8, 28)] for i in range(1, 28)] + performance_negative = [[-1, -random.randrange(8, 28)] for i in range(1, 28)] + + return { + "navigation": [ + {"title": _("Dashboard"), "link": "/", "active": True}, + {"title": _("Analytics"), "link": "#"}, + {"title": _("Settings"), "link": "#"}, + ], + "kpi": [ + { + "title": "Total Revenue", + "metric": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "footer": mark_safe(f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress'), + "chart": json.dumps({"labels": [WEEKDAYS[day % 7] for day in range(1, 28)], "datasets": [{"data": average, "borderColor": "#9333ea"}]}), + }, + ], + "chart": json.dumps({ + "labels": [WEEKDAYS[day % 7] for day in range(1, 28)], + "datasets": [ + {"label": "Revenue", "data": positive, "backgroundColor": "var(--color-primary-700)"}, + ], + }), + } \ No newline at end of file diff --git a/utils/apps.py b/utils/apps.py new file mode 100644 index 0000000..4e9b93e --- /dev/null +++ b/utils/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class UtilsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'utils' + + def ready(self): + # Import and run the configure_filer_admin function + from utils import configure_filer_admin + configure_filer_admin() \ No newline at end of file diff --git a/utils/config_getter.py b/utils/config_getter.py new file mode 100644 index 0000000..4eaf50d --- /dev/null +++ b/utils/config_getter.py @@ -0,0 +1,13 @@ +import logging + +from dynamic_preferences.registries import global_preferences_registry + +global_preferences = global_preferences_registry.manager() + + +def get_config(key): + try: + return global_preferences[key] + except Exception as e: + logging.error(f"error gettings config {key}: {e}") + return None diff --git a/utils/country_city_db/GeoLite2-City.mmdb b/utils/country_city_db/GeoLite2-City.mmdb new file mode 100644 index 0000000..177e3ad Binary files /dev/null and b/utils/country_city_db/GeoLite2-City.mmdb differ diff --git a/utils/country_city_db/GeoLite2-Country.mmdb b/utils/country_city_db/GeoLite2-Country.mmdb new file mode 100644 index 0000000..84e9049 Binary files /dev/null and b/utils/country_city_db/GeoLite2-Country.mmdb differ diff --git a/utils/image_utils.py b/utils/image_utils.py new file mode 100644 index 0000000..9f68b9d --- /dev/null +++ b/utils/image_utils.py @@ -0,0 +1,192 @@ +""" +Image and video processing utilities for thumbnail generation +""" +import os +import subprocess +from PIL import Image +from django.conf import settings + + +def create_thumbnail(image_path, size=(300, 300), quality=85, format='WEBP'): + """ + Create a thumbnail for an uploaded image + + Args: + image_path: Absolute path to original image + size: Tuple of (width, height) for thumbnail max dimensions + quality: Image quality (1-95 for JPEG, 1-100 for WebP) + format: Output format ('WEBP', 'JPEG', 'PNG') + + Returns: + Absolute path to thumbnail file + + Raises: + ValueError: If image cannot be processed + FileNotFoundError: If original image doesn't exist + """ + if not os.path.exists(image_path): + raise FileNotFoundError(f"Original image not found: {image_path}") + + try: + # Open and process image + img = Image.open(image_path) + + # Convert RGBA to RGB if needed (for JPEG/WebP compatibility) + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'RGBA': + background.paste(img, mask=img.split()[-1]) + else: + background.paste(img) + img = background + + # Create thumbnail maintaining aspect ratio + img.thumbnail(size, Image.Resampling.LANCZOS) + + # Generate thumbnail path + base, ext = os.path.splitext(image_path) + + # Choose extension based on format + if format.upper() == 'WEBP': + thumb_path = f"{base}_thumb.webp" + img.save(thumb_path, 'WEBP', quality=quality, method=6) + elif format.upper() == 'JPEG': + thumb_path = f"{base}_thumb.jpg" + img.save(thumb_path, 'JPEG', quality=quality, optimize=True) + elif format.upper() == 'PNG': + thumb_path = f"{base}_thumb.png" + img.save(thumb_path, 'PNG', optimize=True) + else: + # Default to WebP + thumb_path = f"{base}_thumb.webp" + img.save(thumb_path, 'WEBP', quality=quality, method=6) + + return thumb_path + + except Exception as e: + raise ValueError(f"Failed to create thumbnail: {str(e)}") + + +def get_image_dimensions(image_path): + """ + Get dimensions of an image + + Args: + image_path: Path to image file + + Returns: + Tuple of (width, height) or None if error + """ + try: + with Image.open(image_path) as img: + return img.size + except Exception: + return None + + +def is_image_file(file_path): + """ + Check if a file is an image based on extension and content + + Args: + file_path: Path to file + + Returns: + Boolean indicating if file is an image + """ + image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'} + ext = os.path.splitext(file_path)[1].lower() + + if ext not in image_extensions: + return False + + try: + with Image.open(file_path) as img: + img.verify() + return True + except Exception: + return False + + +def is_video_file(file_path): + """ + Check if a file is a video based on extension + + Args: + file_path: Path to file + + Returns: + Boolean indicating if file is a video + """ + video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.m4v'} + ext = os.path.splitext(file_path)[1].lower() + return ext in video_extensions + + +def extract_video_thumbnail(video_path, time_offset='00:00:01', size=(300, 300), quality=85): + """ + Extract a frame from video as thumbnail using FFmpeg + + Args: + video_path: Absolute path to video file + time_offset: Time position to extract frame (default: 1 second) + size: Tuple of (width, height) for thumbnail max dimensions + quality: JPEG quality (1-31 for FFmpeg, lower is better, we convert to FFmpeg scale) + + Returns: + Absolute path to thumbnail file or None if failed + + Raises: + FileNotFoundError: If video file doesn't exist + ValueError: If FFmpeg is not installed or extraction fails + """ + if not os.path.exists(video_path): + raise FileNotFoundError(f"Video file not found: {video_path}") + + # Check if FFmpeg is available + try: + subprocess.run(['ffmpeg', '-version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=5) + except (subprocess.TimeoutExpired, FileNotFoundError): + raise ValueError("FFmpeg is not installed or not available in PATH") + + try: + # Generate output path + base, ext = os.path.splitext(video_path) + output_path = f"{base}_thumb.jpg" + + # Convert quality (85 -> 2 in FFmpeg scale) + # FFmpeg uses 1-31 where lower is better, opposite of our 0-100 scale + ffmpeg_quality = max(1, min(31, int((100 - quality) * 31 / 100))) + + # FFmpeg command to extract frame + cmd = [ + 'ffmpeg', + '-ss', time_offset, # Seek to time position (before input for speed) + '-i', video_path, # Input video + '-vframes', '1', # Extract 1 frame + '-vf', f'scale={size[0]}:-1', # Scale to width, maintain aspect ratio + '-q:v', str(ffmpeg_quality), # Quality + '-y', # Overwrite output + output_path + ] + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=15 # 15 second timeout + ) + + if result.returncode == 0 and os.path.exists(output_path): + return output_path + else: + error_msg = result.stderr.decode('utf-8', errors='ignore') + raise ValueError(f"FFmpeg extraction failed: {error_msg[:200]}") + + except subprocess.TimeoutExpired: + raise ValueError("Video thumbnail extraction timed out (>15s)") + except Exception as e: + raise ValueError(f"Failed to extract video thumbnail: {str(e)}") diff --git a/utils/ip_helper.py b/utils/ip_helper.py new file mode 100644 index 0000000..5a1434a --- /dev/null +++ b/utils/ip_helper.py @@ -0,0 +1,32 @@ +# utils/ip_helper.py +import geoip2.database +from django.conf import settings +import os + + +def get_client_ip(request): + """Retrieves the real IP address from the request.""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + # The header contains a list of IPs, the first one is the real client + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + +def get_country_code(ip_address): + """ + Returns the ISO country code (e.g., 'RU', 'US', 'IR') for an IP. + Returns None if IP is invalid or not found. + """ + # Path to your .mmdb file + db_path = os.path.join(settings.BASE_DIR, 'utils', 'country_city_db', 'GeoLite2-Country.mmdb') + + try: + with geoip2.database.Reader(db_path) as reader: + response = reader.country(ip_address) + return response.country.iso_code + except Exception as e: + # Log error in production + print(f"GeoIP Error: {e}") + return None \ No newline at end of file diff --git a/utils/json_editor_field.py b/utils/json_editor_field.py index 950a89c..5242f90 100644 --- a/utils/json_editor_field.py +++ b/utils/json_editor_field.py @@ -1,4 +1,5 @@ import json +<<<<<<< HEAD from django import forms from django.db import models @@ -6,6 +7,71 @@ from django.db import models class JsonEditorWidget(forms.Textarea): template_name = 'fields/json_editor_field.html' +======= +from typing import Any, Optional + +from django import forms +from django.db import models +from django.utils.safestring import mark_safe + +from django.forms import MultiWidget, Widget + + +JSON_EDITOR_CLASSES = [ + "border", + "border-base-200", + "rounded", + "group-[.errors]:border-red-600", + "w-full", + "dark:border-base-700", + "dark:group-[.errors]:border-red-500", +] + + +class JsonEditorWidget(Widget): + template_name = 'account/json_editor_field.html' + + class Media: + css = { + 'all': ('https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/css/jsoneditor.min.css',) + } + js = ('https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js',) + + def __init__(self, attrs: Optional[dict[str, Any]] = None) -> None: + if attrs is None: + attrs = {} + + # Set default title if not provided + if 'title' not in attrs and 'label' in attrs: + attrs['title'] = attrs['label'] + elif 'title' not in attrs: + attrs['title'] = 'JSON Editor' + + super().__init__(attrs) + + self.attrs.update({ + 'class': ' '.join(JSON_EDITOR_CLASSES), + }) + + def render(self, name, value, attrs=None, renderer=None): + if value is None: + value = '{}' + elif isinstance(value, dict): + value = json.dumps(value) + + attrs = self.build_attrs(self.attrs, attrs) + attrs['name'] = name + + # Ensure the schema is properly passed to the template + if 'schema' in self.attrs: + attrs['schema'] = self.attrs['schema'] + + # Pass field name as title if not set + if 'title' not in attrs: + attrs['title'] = name.replace('_', ' ').title() + + return super().render(name, value, attrs, renderer) +>>>>>>> develop class JsonEditorField(models.JSONField): diff --git a/utils/multilang_json_widget.py b/utils/multilang_json_widget.py new file mode 100644 index 0000000..c774174 --- /dev/null +++ b/utils/multilang_json_widget.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from typing import Any, Optional +import json + +from django.conf import settings +from django.forms.widgets import Media, Widget +from django.template.loader import get_template + +try: + from dj_language.models import Language # type: ignore +except Exception: # pragma: no cover - fallback when app is missing + Language = None # type: ignore + +from unfold.widgets import ( + UnfoldAdminTextInputWidget, + UnfoldAdminTextareaWidget, +) +from unfold.contrib.forms.widgets import WysiwygWidget + + +class MultiLanguageJSONWidget(Widget): + """ + Unfold-styled widget for JSONField storing list of objects with keys: + - language_code + - title + + Renders a horizontal, scrollable list of active language codes; clicking a code toggles + the corresponding input rendered using the provided input widget class + (UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget, or WysiwygWidget). + + The widget submits values via sub-inputs named as "__" + and converts them in value_from_datadict to the required JSON string (list[dict]). + """ + + template_name = "utils/widgets/multilang_json_widget.html" + + def __init__( + self, + input_widget_class: type[Widget] | None = None, + attrs: Optional[dict[str, Any]] = None, + ) -> None: + super().__init__(attrs) + if input_widget_class is None: + input_widget_class = UnfoldAdminTextInputWidget + + self.input_widget_class: type[Widget] = input_widget_class + self.input_widget: Widget = input_widget_class() + + @property + def media(self) -> Media: # type: ignore[override] + # Only include child media (e.g. trix for Wysiwyg). JS is inlined in template. + try: + child_media = self.input_widget.media # type: ignore[attr-defined] + except Exception: + child_media = Media() + return child_media + + def _get_active_language_codes(self) -> list[str]: + codes: list[str] = [] + if Language is not None: + try: + codes = list( + Language.objects.filter(status=True).values_list("code", flat=True) # type: ignore[attr-defined] + ) + except Exception: + try: + codes = list(Language.objects.values_list("code", flat=True)) + except Exception: + codes = [] + + if not codes: + codes = [code for code, _ in getattr(settings, "LANGUAGES", [("en", "English")])] + + return list(dict.fromkeys(codes)) + + def _normalize_value(self, value: Any) -> dict[str, Any]: + mapping: dict[str, Any] = {} + if not value: + return mapping + + if isinstance(value, str): + try: + value = json.loads(value) + except Exception: + return mapping + + if isinstance(value, list): + for item in value: + if not isinstance(item, dict): + continue + code = ( + item.get("language_code") + or item.get("code") + or item.get("lang") + or item.get("language") + ) + text = item.get("title") or item.get("value") or item.get("text") + if code and text is not None: + mapping[str(code)] = text + elif isinstance(value, dict): + if "language_code" in value and "title" in value: + mapping[str(value["language_code"])] = value["title"] + else: + for code, text in value.items(): + mapping[str(code)] = text + + return mapping + + def get_context(self, name: str, value: Any, attrs: Optional[dict[str, Any]]): + context = super().get_context(name, value, attrs) + + languages = self._get_active_language_codes() + values_map = self._normalize_value(value) + + # Ensure languages include any language codes present in value + for code in values_map.keys(): + if code not in languages: + languages.append(code) + + # Reorder: languages with existing values first + codes_with_values = [code for code in languages if values_map.get(code) not in (None, "")] + codes_without_values = [code for code in languages if code not in codes_with_values] + languages = [*codes_with_values, *codes_without_values] + + # Build per-language rendered inputs using the child widget + rendered_inputs: list[dict[str, str]] = [] + for code in languages: + input_name = f"{name}__{code}" + rendered_html = self.input_widget.render(input_name, values_map.get(code, ""), attrs) + rendered_inputs.append({"code": code, "html": rendered_html}) + + # Prepare serialized hidden value (JSON string) + serialized_list: list[dict[str, Any]] = [] + for code in languages: + text_value = values_map.get(code) + if text_value not in (None, ""): + serialized_list.append({"language_code": code, "title": text_value}) + + context["widget"].update( + { + "languages": languages, + "inputs": rendered_inputs, + "values_map": values_map, + "field_name": name, + "serialized": json.dumps(serialized_list, ensure_ascii=False), + "has_value_codes": codes_with_values, + } + ) + + return context + + def value_from_datadict(self, data, files, name): + hidden_value = data.get(name) + if hidden_value not in (None, ""): + return hidden_value + + prefix = f"{name}__" + results: list[dict[str, Any]] = [] + + for key in data.keys(): + if not key.startswith(prefix): + continue + code = key[len(prefix) :] + text = data.get(key) + if text not in (None, ""): + results.append({"language_code": code, "title": text}) + + return json.dumps(results, ensure_ascii=False) + + def value_omitted_from_data(self, data, files, name): + prefix = f"{name}__" + return not any(k.startswith(prefix) for k in data.keys()) + + def render(self, name, value, attrs=None, renderer=None): + """ + Override render method to use regular Django template loader + instead of form renderer + """ + if value is None: + value = '' + + context = self.get_context(name, value, attrs) + template = get_template(self.template_name) + return template.render(context) + + + + + + + + diff --git a/utils/pagination.py b/utils/pagination.py new file mode 100644 index 0000000..39ab359 --- /dev/null +++ b/utils/pagination.py @@ -0,0 +1,33 @@ +from rest_framework.pagination import PageNumberPagination, LimitOffsetPagination +from rest_framework.response import Response + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 16 + page_size_query_param = 'page_size' + max_page_size = 100 + + def get_paginated_response(self, data): + return Response({ + 'count': self.page.paginator.count, + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'results': data, + }) + + +class NoPagination(PageNumberPagination): + def paginate_queryset(self, queryset, request, view=None): + self.count = len(queryset) + self.request = request + self.page = None + self.page_size = len(queryset) + return list(queryset) + + def get_paginated_response(self, data): + return Response({ + 'count': self.count, + 'next': None, + 'previous': None, + 'results': data, + }) \ No newline at end of file diff --git a/utils/redis.py b/utils/redis.py index 9d66dd3..d34a636 100644 --- a/utils/redis.py +++ b/utils/redis.py @@ -1,8 +1,23 @@ +<<<<<<< HEAD import random from datetime import datetime, timedelta from redis.exceptions import RedisError +======= +import json +import hashlib +import random +import secrets +from datetime import datetime, timedelta +from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +from redis.exceptions import RedisError + +from django.conf import settings + +>>>>>>> develop from config.redis_config import RedisConfig from utils.exceptions import ServiceUnavailableException, NotFoundException @@ -67,4 +82,52 @@ class RedisManager(RedisConfig): @staticmethod def generate_otp_code() -> int: random_code = random.randint(10000, 99999) - return random_code \ No newline at end of file +<<<<<<< HEAD + return random_code +======= + return random_code + + +class OnlineClassTokenManager(RedisConfig): + """Manage temporary tokens used for joining online classes.""" + + KEY_PREFIX = "online_class_token:" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ttl = getattr(settings, "ONLINE_CLASS_TOKEN_TTL", 300) + + def _build_key(self, token: str) -> str: + return f"{self.KEY_PREFIX}{token}" + + def generate_token(self, course_id: int, user_identifier: str) -> str: + seed = f"{course_id}:{user_identifier}:{secrets.token_urlsafe(16)}" + return hashlib.sha256(seed.encode()).hexdigest() + + def store_token(self, token: str, payload: dict, ttl: Optional[int] = None) -> None: + data = { + **payload, + "generated_at": datetime.utcnow().isoformat() + "Z", + } + self.redis.set(self._build_key(token), json.dumps(data), ex=ttl or self.ttl) + + def get_payload(self, token: str) -> dict: + stored = self.redis.get(self._build_key(token)) + if not stored: + raise NotFoundException("Token not found or has expired.") + return json.loads(stored) + + def delete_token(self, token: str) -> None: + self.redis.delete(self._build_key(token)) + + @staticmethod + def build_entry_url(token: str, base_url: Optional[str] = None) -> str: + base = base_url or getattr(settings, "ONLINE_CLASS_FRONTEND_DOMAIN", getattr(settings, "SITE_DOMAIN", "")) + if not base: + return f"?token={token}" + parsed = urlparse(base) + query_params = dict(parse_qsl(parsed.query)) + query_params["token"] = token + new_query = urlencode(query_params) + return urlunparse(parsed._replace(query=new_query)) +>>>>>>> develop diff --git a/utils/schema.py b/utils/schema.py index 110a701..e4ba409 100644 --- a/utils/schema.py +++ b/utils/schema.py @@ -36,7 +36,10 @@ def get_weekly_timing_schema(): } +<<<<<<< HEAD +======= +>>>>>>> develop def get_course_feature_schema(): return { 'type': "array", @@ -50,3 +53,23 @@ def get_course_feature_schema(): } } } +<<<<<<< HEAD +======= + + +def get_calender_dates_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('')), + 'properties': { + 'year': {'type': 'string', 'format': 'number', 'title': str(_('year'))}, + 'month': {'type': 'string', 'format': 'number', 'title': str(_('month'))}, + 'day': {'type': 'string', 'format': 'number', 'title': str(_('day'))}, + } + } + } +>>>>>>> develop diff --git a/utils/slug.py b/utils/slug.py new file mode 100644 index 0000000..78b299d --- /dev/null +++ b/utils/slug.py @@ -0,0 +1,139 @@ +""" +Smart slug generation utility for Django models. +Handles truncation, counter-based uniqueness, and word-limit preservation. +""" + +from django.utils.text import slugify + + +def generate_smart_slug( + text: str, + model_class, + max_length: int = 100, + field_name: str = "slug", + instance=None, + keep_words: int = 8, + reserve_for_counter: int = 5, # Reserve space for "-1", "-2", etc. (max 5 chars) +) -> str: + """ + Generate a unique, meaningful slug with a max length and word limit. + + This function: + 1. Extracts the first N words from the text + 2. Slugifies them + 3. Truncates to max_length (reserving space for counter if needed) + 4. Adds a counter (-1, -2, etc.) if the slug already exists + + Args: + text (str): The text to slugify (e.g., hadis title) + model_class: The Django model class (e.g., Hadis) + max_length (int): Maximum slug length (default: 100) + field_name (str): The slug field name (default: 'slug') + instance: Current instance to exclude from uniqueness check (optional) + keep_words (int): Number of words to keep (default: 8) + reserve_for_counter (int): Space reserved for counter suffix (default: 5, enough for "-9999") + + Returns: + str: A unique slug within max_length and word constraints + + Raises: + ValueError: If unable to generate unique slug after 1000 attempts + + Examples: + >>> from utils.slugs import generate_smart_slug + >>> from hadis.models import Hadis + >>> + >>> text = "Fatwa on Combining Prayers While Traveling and Missing the Congregational Prayer" + >>> slug = generate_smart_slug(text, Hadis, keep_words=4) + >>> print(slug) + 'fatwa-on-combining-prayers' + >>> + >>> # With counter for duplicate + >>> slug2 = generate_smart_slug(text, Hadis, keep_words=4, instance=hadis) + >>> print(slug2) + 'fatwa-on-combining-prayers-1' + """ + + # Validation: Check if text is valid + if not text or not isinstance(text, str): + fallback = ( + f"{model_class.__name__.lower()}-{instance.id}" + if instance and instance.pk + else f"{model_class.__name__.lower()}-new" + ) + return fallback + + # ==================== STEP 1: Extract first N words ==================== + words = text.strip().split()[:keep_words] + text_shortened = " ".join(words) + + # ==================== STEP 2: Slugify ==================== + base_slug = slugify(text_shortened, allow_unicode=True) + + # ==================== STEP 3: Truncate to max_length ==================== + # Reserve space for potential counter suffix + available_length = max_length - reserve_for_counter + slug = base_slug[:available_length].rstrip("-") + + # ==================== STEP 4: Ensure uniqueness with counter ==================== + counter = 0 # Start at 0 for first attempt (no counter) + original_slug = slug + + while True: + # Build the final slug + if counter == 0: + final_slug = slug # First attempt: no counter + else: + counter_suffix = f"-{counter}" + # Ensure total doesn't exceed max_length + available_for_base = max_length - len(counter_suffix) + final_slug = original_slug[:available_for_base].rstrip("-") + counter_suffix + + # Build filter query + filter_kwargs = {field_name: final_slug} + qs = model_class.objects.filter(**filter_kwargs) + + # Exclude current instance if provided + if instance and instance.pk: + qs = qs.exclude(pk=instance.pk) + + # If no conflict, slug is unique + if not qs.exists(): + return final_slug + + # Try with counter + counter += 1 + + # Safety: prevent infinite loop + if counter > 1000: + raise ValueError( + f"Could not generate unique slug for text: '{text}'. " + f"Attempted 1000+ variations of '{original_slug}'" + ) + + return slug + + +# ============================================================================ +# Backward compatibility aliases +# ============================================================================ + +def generate_unique_slug( + text: str, + model_class, + max_length: int = 100, + field_name: str = "slug", + instance=None, +) -> str: + """ + Backward compatible version without word limit. + Uses default keep_words=999 (essentially unlimited). + """ + return generate_smart_slug( + text=text, + model_class=model_class, + max_length=max_length, + field_name=field_name, + instance=instance, + keep_words=999, # No word limit + ) diff --git a/utils/styles.css b/utils/styles.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/utils/styles.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/utils/validators.py b/utils/validators.py index 548093d..ac831f8 100644 --- a/utils/validators.py +++ b/utils/validators.py @@ -21,6 +21,10 @@ def validate_possible_number(phone, country=None): return phone_number def validate_type_code(value): +<<<<<<< HEAD +======= + from rest_framework import serializers +>>>>>>> develop if not value.isdigit(): raise serializers.ValidationError('کد باید شامل اعداد باشد.') if len(value) != 5: diff --git a/video_link.json b/video_link.json new file mode 100644 index 0000000..9e53cba --- /dev/null +++ b/video_link.json @@ -0,0 +1,57 @@ +{ + "videos": [ + { + "slug": "0X5UNtRx", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/eldar-ibragimov/osnovnaia-tsel-meropriiatii-po-imam-khuseinu-a-kerbelaii-eldar-ibragimov.mp4" + }, + { + "slug": "LLAI_kDX", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/imam-khamenei/allakh-nash-pokrovitel-aiatolla-khamenei-14-03-2019.mp4" + }, + { + "slug": "1-0QHW", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/kurban-mirzakhanov/1-kto-ubil-imama-khuseina-mir-emu-omeiady-obeziany-na-minbare-proroka-s.mp4" + }, + { + "slug": "wlSKxPZq", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/chingiz-ramazanov/ramadan-vstuplenie-khadzhi-chingiz-ramazanov.mp4" + }, + { + "slug": "_8ilJ1E7iwE", + "video": null + }, + { + "slug": "hBPQjVRz", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/nazim-zeinalov/tsena-nashei-zhizni.mp4" + }, + { + "slug": "1-2014", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/shakhin-khasanli/peredacha-dzhuma-dukhovnoe-razvitie-1-vvedenie-14-03-2014.mp4" + }, + { + "slug": "2-7vpF", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/kurban-mirzakhanov/2-kto-ubil-imama-khuseina-mir-emu-dostovernost-khadisa-obeziany-na-minbare.mp4" + }, + { + "slug": "299r-opV", + "video": "https://dl.razaviportal.com/hosseiniyeh/RU/ramil-badalov/khadzhi-ramil-prirovniali-k-allakhu-2024.mp4" + } + ], + "youtube_links": [ + "https://dl.razaviportal.com/hosseiniyeh/RU/eldar-ibragimov/udel-vsevyshnego-allakha-v-mesiats-mukharram-khadzhi-eldar-ibragimov-26-09-2021.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/alekber-gasymov/25-01-2020-chto-oznachaet-imia-zakhra-v-chiom-posyl-vozglasa-fatimy-az-zakhry-alekber-gasymov.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/eldar-ibragimov/den-rozhdenie-imama-makhdi-a-khadzhi-eldar-ibragimov-14-02-2025.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/imam-khomeini/snimite-s-nikh-chalmu-imam-khomeini-k-s.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/khusein-mukhammadi/1-liubov-allakha-udel-tekh-kto-zhiviot-radi-nego-taina-nochnogo-puteshestviia.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/chingiz-ramazanov/ummul-baniin-nastoiashchii-geroi-khadzhi-chingiz-ramazanov.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/chingiz-ramazanov/blagodarnost-chingiz-ramazanov-31-01-2025.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/chingiz-ramazanov/dva-portreta-khadzhi-chingiz-ramazanov.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/airat-baeshev/zavershenie-mesiatsa-ramazan-airat-baeshev-12-04-2024.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/dzheikhun-mamedov/1-leksicheskoe-znachenie-slova-akhlak-dzheikhun-mamedov.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/imam-khamenei/aromat-revoliutsii-aiatolla-khamenei.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/kurban-mirzakhanov/3-kto-ubil-imama-khuseina-a-tysiacha-obezianikh-mesiatsev.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/chingiz-ramazanov/zemlia-kerbely-khadzhi-chingiz-ramazanov-02-02-2025.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/imam-khamenei/ne-zhelaite-smerti-aiatolla-khamenei.mp4", + "https://dl.razaviportal.com/hosseiniyeh/RU/eldar-ibragimov/posledstviia-strasti-khadzhi-eldar-ibragimov-22-11-2024.mp4" + ] +}