From fd54052c2af7b7972e6ae2280c2c362b22e81493 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 3 Aug 2025 15:53:41 +0330 Subject: [PATCH] Refactor API Documentation System and optimize Hadis data scripts - Removed the API documentation README file as it is no longer needed. - Added a new script to optimize Hadis transmitter chains, ensuring a maximum of 5 transmitters and exactly one gap. - Enhanced the Hadis data seeding script for better performance with batch operations and duplicate checks. - Updated utility functions to streamline thumbnail generation and improve code readability. --- CLAUDE.md | 80 ++++++ apps/account/admin/professor.py | 10 +- apps/account/admin/student.py | 2 +- .../management/commands/migrate_user_roles.py | 158 +++++++++++ apps/account/manager.py | 13 +- apps/account/middleware/admin_access.py | 114 ++++++++ apps/account/models/user.py | 90 +++++++ apps/account/serializers/user.py | 9 +- apps/account/tests/test_multiple_roles.py | 240 +++++++++++++++++ apps/certificate/admin.py | 3 +- apps/certificate/migrations/0001_initial.py | 1 - apps/chat/management/__init__.py | 1 + apps/chat/management/commands/README.md | 62 +++++ apps/chat/management/commands/__init__.py | 1 + .../management/commands/clear_chat_data.py | 79 ++++++ apps/course/admin/course.py | 40 ++- apps/course/admin/lesson.py | 7 +- apps/course/admin/professor_base.py | 181 +++++++++++++ apps/course/serializers/course.py | 6 +- apps/course/tests/test_multiple_roles_api.py | 216 +++++++++++++++ apps/hadis/management/commands/README.md | 56 ++++ .../management/commands/seed_hadis_data.py | 98 +++---- .../management/commands/test_safe_seeding.py | 152 +++++++++++ apps/quiz/admin/quiz.py | 15 +- apps/transaction/views.py | 7 +- config/settings/base.py | 3 +- docs/API_Documentation_System_README.md | 252 ------------------ scripts/optimize_hadis_transmitters.py | 252 ++++++++++++++++++ utils/__init__.py | 28 +- 29 files changed, 1825 insertions(+), 351 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/account/management/commands/migrate_user_roles.py create mode 100644 apps/account/middleware/admin_access.py create mode 100644 apps/account/tests/test_multiple_roles.py create mode 100644 apps/chat/management/__init__.py create mode 100644 apps/chat/management/commands/README.md create mode 100644 apps/chat/management/commands/__init__.py create mode 100644 apps/chat/management/commands/clear_chat_data.py create mode 100644 apps/course/admin/professor_base.py create mode 100644 apps/course/tests/test_multiple_roles_api.py create mode 100644 apps/hadis/management/commands/test_safe_seeding.py delete mode 100644 docs/API_Documentation_System_README.md create mode 100755 scripts/optimize_hadis_transmitters.py 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/apps/account/admin/professor.py b/apps/account/admin/professor.py index 1f81d3b..82fecc0 100644 --- a/apps/account/admin/professor.py +++ b/apps/account/admin/professor.py @@ -75,8 +75,8 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable): messages.error(request, f"A professor with the email {email} already exists.") return - # If user exists but is not a professor, convert them to professor - existing_user.user_type = User.UserType.PROFESSOR + # اضافه کردن نقش professor بدون حذف نقش‌های قبلی + existing_user.add_role('professor') # Update user fields from form data existing_user.fullname = form.cleaned_data.get('fullname') @@ -92,10 +92,6 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable): # Save the user existing_user.save() - # Add to professor group - professor_group, _ = Group.objects.get_or_create(name="Professor Group") - existing_user.groups.add(professor_group) - # Show success message messages.success(request, f"The user with email {email} has been converted to a professor.") @@ -107,7 +103,7 @@ class ProfessorUserAdmin(UserAdmin, AjaxDatatable): obj.set_password(form.cleaned_data['password1']) if obj: # Only proceed if obj is not None - obj.user_type = User.UserType.PROFESSOR + obj.add_role('professor') super().save_model(request, obj, form, change) @admin.display(description='Phone Number') diff --git a/apps/account/admin/student.py b/apps/account/admin/student.py index 38aeddb..abb857c 100644 --- a/apps/account/admin/student.py +++ b/apps/account/admin/student.py @@ -63,7 +63,7 @@ class StudentUserAdmin(UserAdmin, AjaxDatatable): def save_model(self, request, obj, form, change): if not change: obj.set_password(form.cleaned_data['password1']) - obj.user_type = User.UserType.STUDENT + obj.add_role('student') super().save_model(request, obj, form, change) 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 87acc88..fac4a9c 100644 --- a/apps/account/manager.py +++ b/apps/account/manager.py @@ -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/models/user.py b/apps/account/models/user.py index ab5a8f0..cdd123e 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -102,6 +102,96 @@ 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 class Meta: diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index e6fe2ef..8fe61f8 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -15,15 +15,16 @@ class UserProfileSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) fullname = serializers.CharField(required=False) gender = serializers.ChoiceField( - choices=User.GenderChoices.choices, - required=False, - help_text="Select the user's gender." + choices=User.GenderChoices.choices, + required=False, + help_text="Select the user's gender." ) fcm = serializers.CharField(required=False, help_text="Firebase Cloud Messaging token.") + class Meta: 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'] + read_only_fields = ['email', 'info', 'skill', 'device_id'] # def validate_email(self, value): # if User.objects.filter(email=value).exists(): 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/certificate/admin.py b/apps/certificate/admin.py index 4efb5cb..92943e1 100644 --- a/apps/certificate/admin.py +++ b/apps/certificate/admin.py @@ -8,9 +8,10 @@ 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(ModelAdmin): +class CertificateAdmin(CertificateBaseAdmin): list_display = ['student', 'course', 'certificate_status', 'created_at'] list_filter = ['status', 'created_at'] search_fields = ['id', 'student__username', 'student__email', 'course__title'] diff --git a/apps/certificate/migrations/0001_initial.py b/apps/certificate/migrations/0001_initial.py index 6faf2f3..cc3af2b 100644 --- a/apps/certificate/migrations/0001_initial.py +++ b/apps/certificate/migrations/0001_initial.py @@ -12,7 +12,6 @@ class Migration(migrations.Migration): dependencies = [ ('account', '0001_initial'), ('course', '0001_initial'), - ('filer', '0017_image__transparent'), ] operations = [ 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/course/admin/course.py b/apps/course/admin/course.py index 55f2bbe..fbdb5a1 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -28,6 +28,7 @@ from unfold.widgets import ( UnfoldAdminSplitDateTimeWidget, UnfoldAdminTextInputWidget, ) +from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlossaryBaseAdmin from unfold.contrib.forms.widgets import ArrayWidget from django.contrib.postgres.fields import ArrayField @@ -226,7 +227,7 @@ class AddStudentForm(forms.Form): ) -class CourseAdmin(ModelAdmin): +class CourseAdmin(DirectCourseAdmin): form = CourseForm inlines = [CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, ParticipantInline] list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online') @@ -359,13 +360,12 @@ class CourseAdmin(ModelAdmin): 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 is the professor of this course - return course and hasattr(request.user, 'professor') and course.professor_id == request.user.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 @@ -396,13 +396,17 @@ class CourseAdmin(ModelAdmin): 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, + request, _(f"Student {student.fullname} has been successfully added to {course.title}") ) @@ -422,13 +426,21 @@ class CourseAdmin(ModelAdmin): ) -class GlossaryAdmin(ModelAdmin): +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(ModelAdmin): +class CourseGlossaryAdmin(CourseRelatedAdmin): list_display = ('course', 'glossary_title', 'glossary_description') list_filter = ('course',) search_fields = ('glossary__title', 'glossary__description', 'course__title') @@ -476,7 +488,7 @@ class AttachmentAdminForm(forms.ModelForm): return file_name -class AttachmentAdmin(ModelAdmin): +class AttachmentAdmin(AttachmentGlossaryBaseAdmin): form = AttachmentAdminForm list_display = ('title', 'file', 'file_size') search_fields = ('title', 'file') @@ -486,8 +498,16 @@ class AttachmentAdmin(ModelAdmin): obj.file_size = obj.file.size super().save_model(request, obj, form, change) + def is_used_in_professor_courses(self, user, obj): + """آیا این attachment در دوره‌های استاد استفاده شده؟""" + return obj.courseattachment_set.filter(course__professor=user).exists() -class CourseAttachmentAdmin(ModelAdmin): + 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') @@ -514,3 +534,5 @@ project_admin_site.register(CourseGlossary, CourseGlossaryAdmin) project_admin_site.register(Attachment, AttachmentAdmin) project_admin_site.register(CourseAttachment, CourseAttachmentAdmin) project_admin_site.register(Participant, ParticipantAdmin) + +# مدل‌های ProfessorUser و StudentUser قبلاً در admin های مربوطه ثبت شده‌اند diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py index f4e334a..e367bb1 100644 --- a/apps/course/admin/lesson.py +++ b/apps/course/admin/lesson.py @@ -17,6 +17,7 @@ from unfold.widgets import ( ) 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 @@ -88,7 +89,7 @@ class LessonAdmin(ModelAdmin): ) -class CourseLessonAdmin(ModelAdmin): +class CourseLessonAdmin(CourseRelatedAdmin): form = CourseLessonForm list_display = ('title', 'course', 'display_duration', 'is_active', 'priority') list_filter = ( @@ -136,4 +137,6 @@ class LessonCompletionAdmin(ModelAdmin): # Register with the project admin site project_admin_site.register(Lesson, LessonAdmin) project_admin_site.register(CourseLesson, CourseLessonAdmin) -project_admin_site.register(LessonCompletion, LessonCompletionAdmin) \ No newline at end of file +project_admin_site.register(LessonCompletion, LessonCompletionAdmin) + +# Lesson قبلاً ثبت شده است \ No newline at end of file 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/serializers/course.py b/apps/course/serializers/course.py index a12a266..e64881f 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -99,7 +99,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): price = serializers.SerializerMethodField() discount_percentage = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField() - is_free = serializers.SerializerMethodField() + is_free = serializers.SerializerMethodField() class Meta: model = Course @@ -341,6 +341,10 @@ class MyCourseListSerializer(serializers.ModelSerializer): def _is_participant(self, student, course): """Helper method to check if a student is a participant in the given course.""" + # اگر کاربر استاد دوره است، دسترسی کامل دارد + if course.professor == student: + return True + # در غیر این صورت چک می‌کنیم که آیا participant است یا خیر return Participant.objects.filter(student=student, course=course).exists() def _get_authenticated_user(self): 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/hadis/management/commands/README.md b/apps/hadis/management/commands/README.md index a5b389c..d8f4a22 100644 --- a/apps/hadis/management/commands/README.md +++ b/apps/hadis/management/commands/README.md @@ -2,7 +2,11 @@ ## 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 @@ -47,12 +51,25 @@ python manage.py seed_hadis_data --clear --images-dir scripts/seed_images --xmin - 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 @@ -70,3 +87,42 @@ Creating Hadis 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/seed_hadis_data.py b/apps/hadis/management/commands/seed_hadis_data.py index 321dd97..1e0cd54 100644 --- a/apps/hadis/management/commands/seed_hadis_data.py +++ b/apps/hadis/management/commands/seed_hadis_data.py @@ -227,13 +227,13 @@ class Command(BaseCommand): self.stdout.write("Creating Hadis Statuses...") statuses_data = [ - {'title': 'Authentic', 'color': 'green', 'order': 1}, - {'title': 'Good', 'color': 'blue', 'order': 2}, - {'title': 'Weak', 'color': 'yellow', 'order': 3}, - {'title': 'Fabricated', 'color': 'red', 'order': 4}, - {'title': 'Interrupted', 'color': 'orange', 'order': 5}, - {'title': 'Broken', 'color': 'purple', 'order': 6}, - {'title': 'Unknown', 'color': 'gray', 'order': 7}, + {'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 = [] @@ -273,12 +273,12 @@ class Command(BaseCommand): self.stdout.write("Creating Hadis Tags...") tags_data = [ - 'Worship', 'Prayer', 'Fasting', 'Hajj', 'Zakat', 'Khums', - 'Ethics', 'Patience', 'Gratitude', 'Trust', 'Piety', 'Justice', - 'Fiqh', 'Rulings', 'Halal', 'Haram', 'Mustahab', 'Makruh', - 'Interpretation', 'Quran', 'Verses', 'Surah', 'Recitation', - 'Imamate', 'Authority', 'Infallibles', 'Prophets Family', - 'Supplication', 'Remembrance', 'Forgiveness', 'Praise', 'Monotheism' + 'Поклонение', 'Молитва', 'Пост', 'Хадж', 'Закят', 'Хумс', + 'Нравственность', 'Терпение', 'Благодарность', 'Доверие', 'Богобоязненность', 'Справедливость', + 'Фикх', 'Постановления', 'Халяль', 'Харам', 'Мустахаб', 'Макрух', + 'Толкование', 'Коран', 'Аяты', 'Сура', 'Чтение', + 'Имамат', 'Власть', 'Непорочные', 'Семья Пророка', + 'Мольба', 'Поминание', 'Прощение', 'Восхваление', 'Единобожие' ] tags = [] @@ -338,8 +338,8 @@ class Command(BaseCommand): self.stdout.write("Creating Hadis Sects...") sects_data = [ - {'sect_type': 'shia', 'title': 'Shia Twelvers', 'is_active': True, 'order': 1}, - {'sect_type': 'sunni', 'title': 'Sunni', 'is_active': True, 'order': 2}, + {'sect_type': 'shia', 'title': 'Шииты-двунадесятники', 'is_active': True, 'order': 1}, + {'sect_type': 'sunni', 'title': 'Сунниты', 'is_active': True, 'order': 2}, ] sects = [] @@ -426,12 +426,12 @@ class Command(BaseCommand): # Quran categories - create one by one to avoid MPTT issues quran_categories_data = [ - {'title': 'Quran Interpretation', 'order': 1}, - {'title': 'Verses of Rulings', 'order': 2}, - {'title': 'Quran Stories', 'order': 3}, - {'title': 'Virtues of Surahs', 'order': 4}, - {'title': 'Quran Miracles', 'order': 5}, - {'title': 'Quranic Sciences', 'order': 6}, + {'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 @@ -456,22 +456,22 @@ class Command(BaseCommand): for parent_category in quran_parent_categories: child_categories_data = [] - if parent_category.title == 'Quran Interpretation': + if parent_category.title == 'Толкование Корана': child_categories_data = [ - {'title': 'Surah Al-Fatiha Interpretation', 'order': 1}, - {'title': 'Surah Al-Baqara Interpretation', 'order': 2}, - {'title': 'Surah Al Imran Interpretation', 'order': 3}, + {'title': 'Толкование суры Аль-Фатиха', 'order': 1}, + {'title': 'Толкование суры Аль-Бакара', 'order': 2}, + {'title': 'Толкование суры Аль Имран', 'order': 3}, ] - elif parent_category.title == 'Verses of Rulings': + elif parent_category.title == 'Аяты постановлений': child_categories_data = [ - {'title': 'Prayer Verses', 'order': 1}, - {'title': 'Fasting Verses', 'order': 2}, - {'title': 'Zakat Verses', 'order': 3}, + {'title': 'Аяты о молитве', 'order': 1}, + {'title': 'Аяты о посте', 'order': 2}, + {'title': 'Аяты о закяте', 'order': 3}, ] - elif parent_category.title == 'Quran Stories': + elif parent_category.title == 'Коранические истории': child_categories_data = [ - {'title': 'Prophets Stories', 'order': 1}, - {'title': 'Righteous People Stories', 'order': 2}, + {'title': 'Истории пророков', 'order': 1}, + {'title': 'Истории праведников', 'order': 2}, ] # Create child categories one by one @@ -496,12 +496,12 @@ class Command(BaseCommand): # Hadith categories - create one by one self.stdout.write(" 📚 Creating Hadith categories...") hadith_categories_data = [ - {'title': 'Book of Purification', 'order': 1}, - {'title': 'Book of Prayer', 'order': 2}, - {'title': 'Book of Fasting', 'order': 3}, - {'title': 'Book of Hajj', 'order': 4}, - {'title': 'Book of Zakat', 'order': 5}, - {'title': 'Book of Ethics', 'order': 6}, + {'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 @@ -526,22 +526,22 @@ class Command(BaseCommand): for parent_category in hadith_parent_categories: child_categories_data = [] - if parent_category.title == 'Book of Purification': + if parent_category.title == 'Книга очищения': child_categories_data = [ - {'title': 'Ablution', 'order': 1}, - {'title': 'Full Bath', 'order': 2}, - {'title': 'Dry Ablution', 'order': 3}, + {'title': 'Омовение', 'order': 1}, + {'title': 'Полное омовение', 'order': 2}, + {'title': 'Сухое омовение', 'order': 3}, ] - elif parent_category.title == 'Book of Prayer': + elif parent_category.title == 'Книга молитвы': child_categories_data = [ - {'title': 'Prayer Times', 'order': 1}, - {'title': 'Qibla Direction', 'order': 2}, - {'title': 'Congregational Prayer', 'order': 3}, + {'title': 'Времена молитв', 'order': 1}, + {'title': 'Направление киблы', 'order': 2}, + {'title': 'Коллективная молитва', 'order': 3}, ] - elif parent_category.title == 'Book of Ethics': + elif parent_category.title == 'Книга нравственности': child_categories_data = [ - {'title': 'Patience and Gratitude', 'order': 1}, - {'title': 'Justice and Honesty', 'order': 2}, + {'title': 'Терпение и благодарность', 'order': 1}, + {'title': 'Справедливость и честность', 'order': 2}, ] # Create child categories one by one 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/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py index 24ce71d..1956e12 100644 --- a/apps/quiz/admin/quiz.py +++ b/apps/quiz/admin/quiz.py @@ -25,11 +25,18 @@ class QuizAdmin(ModelAdmin): queryset = super().get_queryset(request).annotate( questions_count=Count('questions') ) - - if request.user.groups.filter(name="Professor Group").exists(): + + # اولویت اول: 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 + + return queryset.none() def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) diff --git a/apps/transaction/views.py b/apps/transaction/views.py index 83b9446..fc85c98 100644 --- a/apps/transaction/views.py +++ b/apps/transaction/views.py @@ -35,9 +35,10 @@ class TransactionParticipantCreateView(generics.CreateAPIView): 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) + + # به جای تغییر user_type، فقط نقش student را اضافه می‌کنیم + if not user.has_role('student'): + user.add_role('student') participant, created = Participant.objects.get_or_create( student=user, diff --git a/config/settings/base.py b/config/settings/base.py index 9f1613b..9761a72 100755 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -121,11 +121,12 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - # "django.contrib.auth.middleware.LoginRequiredMiddleware", + # "django.contrib.auth.middleware.LoginRequiredMiddleware", 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'config.language_code_middleware.language_middleware', 'config.enhanced_auth_middleware.enhanced_auth_middleware', + 'apps.account.middleware.admin_access.AdminAccessMiddleware', ] ROOT_URLCONF = 'config.urls' diff --git a/docs/API_Documentation_System_README.md b/docs/API_Documentation_System_README.md deleted file mode 100644 index 1e76b2a..0000000 --- a/docs/API_Documentation_System_README.md +++ /dev/null @@ -1,252 +0,0 @@ -# Imam Javad API Documentation System - -## Overview - -This project implements a comprehensive custom API documentation system that replaces the default Swagger UI with a beautiful, secure, and user-friendly interface. The system is designed specifically for the Imam Javad educational platform and includes advanced authentication, responsive design, and professional styling. - -## Features - -### 🔐 Security & Access Control -- **Admin-only access**: All documentation endpoints require staff member authentication -- **Token-based authentication**: Secure API token management for testing endpoints -- **Session management**: Persistent authentication state across documentation systems -- **Automatic redirects**: Unauthorized users are redirected to admin login - -### 🎨 Custom Documentation Interface -- **Responsive sidebar navigation**: Collapsible app sections with smooth animations -- **Interactive endpoint explorer**: Click to navigate and highlight specific endpoints -- **Beautiful JSON viewer**: Syntax-highlighted response examples with Prism.js -- **Mobile-friendly design**: Optimized for all screen sizes -- **Professional styling**: Modern gradient backgrounds and smooth transitions - -### 🔧 Enhanced Swagger UI -- **Fixed authentication banner**: Always-visible user info and token management -- **Custom branding**: Imam Javad themed colors and styling -- **Token injection**: Automatic authorization header injection for API testing -- **Integrated navigation**: Seamless links between documentation systems - -## System Architecture - -### File Structure -``` -apps/api/views/ -├── __init__.py -├── documentation.py # Custom documentation view -├── swagger_views.py # Enhanced Swagger views with auth -└── api_views.py # Original API views - -templates/ -├── api/ -│ └── documentation.html # Main documentation template -└── swagger/ - ├── ui.html # Custom Swagger UI template - └── auth.html # Token authentication template - -config/ -├── urls.py # Updated URL configuration -└── enhanced_auth_middleware.py # Custom authentication middleware -``` - -### URL Endpoints - -#### Documentation System -- `/en/docs/` - Main API documentation interface -- `/en/swagger/` - Enhanced Swagger UI with authentication -- `/en/swagger-auth/` - Token authentication management -- `/en/swagger-auth/clear/` - Clear authentication session -- `/en/redoc/` - Protected ReDoc interface - -#### API Structure -The documentation covers all major app endpoints: -- **Account Management** (`/api/account/`) - User auth, registration, profiles -- **Course System** (`/api/courses/`) - Educational courses and lessons -- **Hadis Collection** (`/api/hadis/`) - Islamic hadis texts and categories -- **Digital Library** (`/api/library/`) - Books and downloadable resources -- **Video Content** (`/api/videos/`) - Educational video content -- **Podcast Platform** (`/api/podcast/`) - Audio content and episodes -- **Quiz System** (`/api/quiz/`) - Interactive assessments -- **Bookmarks & Ratings** (`/api/bookmarks/`) - User content management - -## Setup Instructions - -### 1. Authentication Setup -The system automatically creates middleware that handles authentication for protected paths. Admin users get automatic token generation for API access. - -### 2. Admin User Creation -```python -# Create admin user (already done in implementation) -from apps.account.models import User -from rest_framework.authtoken.models import Token - -admin_user = User.objects.create( - email='admin@imamjavad.com', - fullname='Admin User', - is_staff=True, - is_superuser=True, - user_type=User.UserType.SUPER_ADMIN -) -admin_user.set_password('admin123') -admin_user.save() - -# Get admin token for API testing -token, _ = Token.objects.get_or_create(user=admin_user) -print(f"Admin token: {token.key}") -``` - -### 3. Accessing the Documentation - -1. **Login to Admin Panel**: Visit `/en/admin/` and login with admin credentials -2. **Access Documentation**: Navigate to `/en/docs/` for the main documentation -3. **Use Swagger UI**: Visit `/en/swagger/` for interactive API testing -4. **Manage Tokens**: Use `/en/swagger-auth/` for token authentication - -## Usage Guide - -### Main Documentation Interface - -1. **Sidebar Navigation**: - - Click on app names to expand/collapse endpoint lists - - Click on specific endpoints to scroll to their documentation - - Mobile users can toggle sidebar with the hamburger menu - -2. **Endpoint Documentation**: - - Each endpoint shows HTTP method, URL, and description - - Parameters table with types and requirements - - Interactive response examples with syntax highlighting - - Tabbed interface for different response types - -3. **Action Buttons**: - - "Swagger UI" button links to interactive testing interface - - "ReDoc" button provides alternative documentation view - -### Swagger UI Interface - -1. **Authentication Banner**: - - Shows current user information and authentication status - - Provides quick access to token management - - Links to main documentation - -2. **Token Management**: - - Enter 40-character API tokens for testing - - Automatic token injection into API requests - - Session persistence across page reloads - -3. **API Testing**: - - All endpoints automatically include authentication headers - - Interactive request/response testing - - Real-time API exploration - -## Customization - -### Adding New Endpoints -Update the `_get_api_structure()` method in `apps/api/views/documentation.py`: - -```python -def _get_api_structure(self): - return { - 'new_app': { - 'name': 'New App Name', - 'description': 'App description', - 'endpoints': [ - { - 'name': 'Endpoint Name', - 'method': 'GET', - 'url': '/api/new-app/endpoint/', - 'description': 'Endpoint description', - 'parameters': [...], - 'response_examples': {...} - } - ] - } - } -``` - -### Styling Customization -Modify CSS variables in `templates/api/documentation.html`: - -```css -:root { - --primary-color: #2c3e50; - --secondary-color: #3498db; - --success-color: #27ae60; - /* Add your custom colors */ -} -``` - -### Branding Updates -- Update project name in templates and views -- Modify color schemes and gradients -- Add custom logos and icons -- Update contact information and licensing - -## Security Considerations - -### Access Control -- All documentation endpoints require `@staff_member_required` decorator -- Middleware automatically handles authentication for protected paths -- Session-based token management with validation -- Automatic cleanup of invalid tokens - -### Token Security -- 40-character Django REST framework tokens -- Session storage with server-side validation -- Automatic token refresh and cleanup -- User activity tracking and session management - -## Troubleshooting - -### Common Issues - -1. **403 Forbidden on Documentation Pages** - - Ensure user has `is_staff=True` - - Check middleware configuration - - Verify admin login session - -2. **Token Authentication Not Working** - - Verify token is exactly 40 characters - - Check token exists in database - - Ensure user account is active - -3. **Responsive Design Issues** - - Clear browser cache - - Check viewport meta tag - - Test on different screen sizes - -### Debug Mode -Enable Django debug mode to see detailed error messages: -```python -DEBUG = True # in settings -``` - -## Performance Optimization - -### Caching -- Static assets are cached with appropriate headers -- JSON responses use browser caching -- Template fragments can be cached for better performance - -### Mobile Optimization -- Responsive images and media queries -- Touch-friendly interface elements -- Optimized loading for mobile networks - -## Contributing - -When adding new features or endpoints: - -1. Update the API structure in `documentation.py` -2. Add appropriate response examples -3. Test on multiple screen sizes -4. Ensure security requirements are met -5. Update this documentation - -## License - -This documentation system is part of the Imam Javad educational platform and follows the project's MIT License. - ---- - -**Admin Credentials for Testing:** -- Email: `admin@imamjavad.com` -- Password: `admin123` -- API Token: `632a324083da7c224361fc61eb5882633c1c575b` 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/utils/__init__.py b/utils/__init__.py index 6a22de4..acd3b4d 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -42,26 +42,24 @@ def get_thumbs(obj, request: HttpRequest = None) -> dict: return {} try: - from easy_thumbnails.files import get_thumbnailer - + # تعریف سه سایز ثابت + sizes = ['sm', 'md', 'lg'] thumbnail_object = {} - thumbs = qs_thumbs() - # print(f'--> {thumbs}') - # بررسی نوع فیلد و استفاده از روش مناسب - if hasattr(obj, 'easy_thumbnails_thumbnailer'): - # برای فیلدهای FilerImageField - thumbnailer = obj.easy_thumbnails_thumbnailer + # گرفتن URL اصلی تصویر + if hasattr(obj, 'url'): + original_url = obj.url else: - # برای فیلدهای ImageField معمولی - thumbnailer = get_thumbnailer(obj) + return {} - for thumb in thumbs: - url = thumbnailer.get_thumbnail(thumb.as_dict).url + # برای هر سه سایز، همان URL اصلی را برگردان + for size in sizes: if request: - url = request.build_absolute_uri(url) - - thumbnail_object[thumb.name] = url + url = request.build_absolute_uri(original_url) + else: + url = original_url + + thumbnail_object[size] = url return thumbnail_object