From 1aba40c433607d3946bb52a86c4cbeda64d9dfce Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 17 Jun 2026 11:32:17 +0330 Subject: [PATCH] courses can have multi professors update --- apps/account/admin/user.py | 8 +-- apps/account/models/user.py | 4 +- apps/account/views/user.py | 2 +- apps/api/views/admin_dashboard.py | 2 +- apps/api/views/professor_dashboard.py | 16 +++--- apps/certificate/views.py | 2 +- apps/chat/admin.py | 10 ++-- apps/chat/admin_views.py | 2 +- apps/course/access.py | 2 +- apps/course/admin/course.py | 14 +++--- apps/course/admin/professor_base.py | 6 +-- .../commands/ensure_course_rooms.py | 4 +- ...move_course_professor_course_professors.py | 23 +++++++++ apps/course/models/course.py | 5 +- apps/course/serializers/admin.py | 49 +++++++++++++++++-- apps/course/serializers/course.py | 19 ++++--- apps/course/signals.py | 34 +++++++------ apps/course/views/admin.py | 24 ++++----- apps/course/views/course.py | 15 +++--- apps/course/views/professor.py | 5 +- apps/quiz/admin/quiz.py | 4 +- apps/quiz/views/admin.py | 8 +-- apps/transaction/views.py | 2 +- 23 files changed, 163 insertions(+), 97 deletions(-) create mode 100644 apps/course/migrations/0016_remove_course_professor_course_professors.py diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py index 91c5eba..9a62d4f 100644 --- a/apps/account/admin/user.py +++ b/apps/account/admin/user.py @@ -211,7 +211,7 @@ class StudentParticipantInline(StackedInline): def get_queryset(self, request): qs = super().get_queryset(request) - return qs.select_related('course', 'course__professor') + return qs.select_related('course').prefetch_related('course__professors') @admin.display(description=_('Course Status')) def course_status(self, obj): @@ -221,8 +221,10 @@ class StudentParticipantInline(StackedInline): @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 + if obj.course: + professors = obj.course.professors.all() + if professors: + return ", ".join(p.fullname or p.email for p in professors) return '-' def has_add_permission(self, request, obj=None): diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 0d3b94c..fddbdb3 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -261,7 +261,6 @@ class User(AbstractUser): self.is_admin_panel_user() or self.is_professor_panel_user() ) - def can_enroll_course(self): """آیا می‌تواند در دوره ثبت‌نام کند؟""" return True # همه می‌توانند دانش‌آموز باشند @@ -273,7 +272,7 @@ class User(AbstractUser): return True # اولویت دوم: professor - فقط دوره‌های خودش if course and self.has_role('professor'): - return course.professor == self + return course.professors.filter(id=self.id).exists() return False def ensure_professor_profile(self, commit: bool = True) -> bool: @@ -305,7 +304,6 @@ class User(AbstractUser): def _ensure_professor_slug(self): if self.slug: return - base_candidates = [ self.fullname, (self.email.split('@')[0] if self.email else None), diff --git a/apps/account/views/user.py b/apps/account/views/user.py index ed8261b..d2d74fd 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -661,7 +661,7 @@ class AdminUserViewSet(ModelViewSet): if self.request.user.user_type == 'professor': queryset = queryset.filter( Q(user_type='student') | Q(user_type='client'), - participated_courses__course__professor_id=self.request.user.id + participated_courses__course__professors=self.request.user ) # Handle Search diff --git a/apps/api/views/admin_dashboard.py b/apps/api/views/admin_dashboard.py index 06df6b6..cef7800 100644 --- a/apps/api/views/admin_dashboard.py +++ b/apps/api/views/admin_dashboard.py @@ -115,7 +115,7 @@ class AdminDashboardStatsView(APIView): { "id": c.id, "title": extract_text_from_json(c.title), - "professor": c.professor.fullname if c.professor else "Unknown", + "professor": ", ".join(p.fullname for p in c.professors.all()) or "Unknown", "participants": c.participant_count } for c in top_courses_qs ] diff --git a/apps/api/views/professor_dashboard.py b/apps/api/views/professor_dashboard.py index cad6fe0..b5126ed 100644 --- a/apps/api/views/professor_dashboard.py +++ b/apps/api/views/professor_dashboard.py @@ -27,20 +27,20 @@ class ProfessorDashboardStatsView(APIView): # 1. CARDS & BASIC STATS # --------------------------------------------------------- # Total courses taught by this professor - courses_count = Course.objects.filter(professor=request.user).count() + courses_count = Course.objects.filter(professors=request.user).count() # Total unique active students enrolled in this professor's courses students_count = Participant.objects.filter( - course__professor=request.user, + course__professors=request.user, is_active=True ).values('student').distinct().count() # Total quizzes created in this professor's courses - quizzes_count = Quiz.objects.filter(course__professor=request.user).count() + quizzes_count = Quiz.objects.filter(course__professors=request.user).count() # Chat rooms count (group rooms for professor's courses + private chats with professor) chat_rooms_count = RoomMessage.objects.filter( - Q(course__professor=request.user) | + Q(course__professors=request.user) | (Q(room_type=RoomMessage.RoomTypeChoices.PRIVATE) & (Q(initiator=request.user) | Q(recipient=request.user))) ).distinct().count() @@ -49,7 +49,7 @@ class ProfessorDashboardStatsView(APIView): # --------------------------------------------------------- # Chat Volume Trend (30 days) professor_rooms = RoomMessage.objects.filter( - Q(course__professor=request.user) | + Q(course__professors=request.user) | (Q(room_type=RoomMessage.RoomTypeChoices.PRIVATE) & (Q(initiator=request.user) | Q(recipient=request.user))) ) chat_volume = ChatMessage.objects.filter( @@ -62,7 +62,7 @@ class ProfessorDashboardStatsView(APIView): chat_chart = [{"date": item['date'].strftime('%Y-%m-%d'), "messages": item['count']} for item in chat_volume] # Course Efficacy: Completed vs Enrolled (Bar Chart for Top 5 Courses) - top_courses_qs = Course.objects.filter(professor=request.user).annotate( + top_courses_qs = Course.objects.filter(professors=request.user).annotate( participant_count=Count('participants') ).order_by('-participant_count')[:5] @@ -81,7 +81,7 @@ class ProfessorDashboardStatsView(APIView): }) # Quiz Participation statistics (all quizzes of the professor) - quizzes = Quiz.objects.filter(course__professor=request.user) + quizzes = Quiz.objects.filter(course__professors=request.user) quizzes_stats = [] for quiz in quizzes: course = quiz.course @@ -112,7 +112,7 @@ class ProfessorDashboardStatsView(APIView): # Leaderboard (Top 5 Performing Students in professor's quizzes) top_students = QuizParticipant.objects.filter( - quiz__course__professor=request.user + quiz__course__professors=request.user ).select_related('user').order_by('-total_score', 'total_timing')[:5] leaderboard = [ diff --git a/apps/certificate/views.py b/apps/certificate/views.py index dcba67e..e8ccbd2 100644 --- a/apps/certificate/views.py +++ b/apps/certificate/views.py @@ -98,7 +98,7 @@ class AdminCertificateViewSet(ModelViewSet): # Professors can only see certificates for students in their courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) # Search query search_query = self.request.query_params.get('search', None) diff --git a/apps/chat/admin.py b/apps/chat/admin.py index e65d160..d7552f4 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -32,9 +32,9 @@ def get_allowed_users_for_room(room): allowed_ids.add(room.recipient_id) if room.room_type == 'group' and room.course_id: - # Add Professor - if room.course.professor_id: - allowed_ids.add(room.course.professor_id) + # Add all Professors + professor_ids = room.course.professors.values_list('id', flat=True) + allowed_ids.update(professor_ids) # Add Active Participants from apps.course.models import Participant @@ -67,7 +67,7 @@ def get_rooms_queryset_for_user(user, queryset): # اساتید و دانشجویان: چت‌های خودشان + چت‌های گروهی که در آن عضو هستند return queryset.filter( personal_q | - Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professor=user) | + Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professors=user) | Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__participants__student=user, course__participants__is_active=True) ).distinct() @@ -305,7 +305,7 @@ class ChatMessageAdmin(ModelAdmin): # اساتید و دانشجویان return queryset.filter( personal_q | - Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__professor=user) | + Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__professors=user) | Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__participants__student=user, room__course__participants__is_active=True) ).distinct() diff --git a/apps/chat/admin_views.py b/apps/chat/admin_views.py index 460cde4..2f0b636 100644 --- a/apps/chat/admin_views.py +++ b/apps/chat/admin_views.py @@ -57,7 +57,7 @@ def api_get_rooms(request): else: rooms = queryset.filter( personal_q | - Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professor=user) | + Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professors=user) | Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__participants__student=user, course__participants__is_active=True) ).distinct()[:50] diff --git a/apps/course/access.py b/apps/course/access.py index 45b69de..41cfaa0 100644 --- a/apps/course/access.py +++ b/apps/course/access.py @@ -8,7 +8,7 @@ def user_has_course_access(user, course): if user.is_staff or user.is_superuser: return True - if course.professor_id == user.id: + if course.professors.filter(id=user.id).exists(): return True return Participant.objects.filter( diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index 73a94fe..b03ce53 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -270,7 +270,7 @@ class CourseAdmin(DirectCourseAdmin): search_fields = ('id','title', 'description') exclude = ('slug', ) readonly_fields = ('final_price', 'final_price_rub') - autocomplete_fields = ('category', 'professor',) + autocomplete_fields = ('category', 'professors',) list_filter_submit = True change_form_show_cancel_button = True @@ -291,7 +291,7 @@ class CourseAdmin(DirectCourseAdmin): fieldsets = ( (None, { - 'fields': ('title', 'category', 'professor', 'thumbnail', 'description', 'short_description') + 'fields': ('title', 'category', 'professors', 'thumbnail', 'description', 'short_description') }), (_('Settings & Status'), { 'fields': ( @@ -340,7 +340,7 @@ class CourseAdmin(DirectCourseAdmin): @display(description=_("Professor")) def display_professor(self, instance): - return instance.professor.fullname + return ", ".join([p.fullname for p in instance.professors.all()]) @display(description=_("Price")) def display_price(self, instance): @@ -433,10 +433,10 @@ class GlossaryAdmin(AttachmentGlossaryBaseAdmin): ordering = ('-id',) def is_used_in_professor_courses(self, user, obj): - return obj.courseglossary_set.filter(course__professor=user).exists() + return obj.courseglossary_set.filter(course__professors=user).exists() def filter_by_professor_usage(self, user, queryset): - return queryset.filter(courseglossary__course__professor=user).distinct() + return queryset.filter(courseglossary__course__professors=user).distinct() class CourseGlossaryAdmin(CourseRelatedAdmin): @@ -495,10 +495,10 @@ class AttachmentAdmin(AttachmentGlossaryBaseAdmin): super().save_model(request, obj, form, change) def is_used_in_professor_courses(self, user, obj): - return obj.courseattachment_set.filter(course__professor=user).exists() + return obj.courseattachment_set.filter(course__professors=user).exists() def filter_by_professor_usage(self, user, queryset): - return queryset.filter(courseattachment__course__professor=user).distinct() + return queryset.filter(courseattachment__course__professors=user).distinct() class CourseAttachmentAdmin(CourseRelatedAdmin): diff --git a/apps/course/admin/professor_base.py b/apps/course/admin/professor_base.py index b0b976a..6152600 100644 --- a/apps/course/admin/professor_base.py +++ b/apps/course/admin/professor_base.py @@ -121,7 +121,7 @@ class CourseRelatedAdmin(ProfessorBaseAdmin): def filter_queryset_for_professor(self, request, queryset): """فیلتر کردن بر اساس دوره‌های استاد""" - return queryset.filter(course__professor=request.user) + return queryset.filter(course__professors=request.user) def get_course_from_object(self, obj): """دریافت Course از object""" @@ -140,7 +140,7 @@ class DirectCourseAdmin(ProfessorBaseAdmin): def filter_queryset_for_professor(self, request, queryset): """فقط دوره‌های خود استاد""" - return queryset.filter(professor=request.user) + return queryset.filter(professors=request.user) class AttachmentGlossaryBaseAdmin(ProfessorBaseAdmin): @@ -178,4 +178,4 @@ class CertificateBaseAdmin(ProfessorBaseAdmin): def filter_queryset_for_professor(self, request, queryset): """فقط certificate های دانش‌آموزان دوره‌های استاد""" - return queryset.filter(course__professor=request.user) + return queryset.filter(course__professors=request.user) diff --git a/apps/course/management/commands/ensure_course_rooms.py b/apps/course/management/commands/ensure_course_rooms.py index d29f4b5..ea1e50b 100644 --- a/apps/course/management/commands/ensure_course_rooms.py +++ b/apps/course/management/commands/ensure_course_rooms.py @@ -26,7 +26,7 @@ class Command(BaseCommand): room_type=RoomMessage.RoomTypeChoices.GROUP, ) - courses_without_group_room = Course.objects.select_related('professor').annotate( + courses_without_group_room = Course.objects.prefetch_related('professors').annotate( has_group_room=Exists(group_room_exists) ).filter( has_group_room=False @@ -53,7 +53,7 @@ class Command(BaseCommand): RoomMessage.objects.create( name=f"{course.title} - Group", description=f"Group chat for course: {course.title}", - initiator=course.professor, + initiator=course.professors.first(), course=course, room_type=RoomMessage.RoomTypeChoices.GROUP, is_locked=course.is_group_chat_locked, diff --git a/apps/course/migrations/0016_remove_course_professor_course_professors.py b/apps/course/migrations/0016_remove_course_professor_course_professors.py new file mode 100644 index 0000000..6f98019 --- /dev/null +++ b/apps/course/migrations/0016_remove_course_professor_course_professors.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.12 on 2026-06-17 10:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_user_password_enc'), + ('course', '0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='course', + name='professor', + ), + migrations.AddField( + model_name='course', + name='professors', + field=models.ManyToManyField(related_name='courses', to='account.professoruser', verbose_name='Professors'), + ), + ] diff --git a/apps/course/models/course.py b/apps/course/models/course.py index a1c0bbf..faf9641 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -333,11 +333,10 @@ class Course(models.Model): title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Course Title')) slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug')) category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name=_('Category')) - professor = models.ForeignKey( + professors = models.ManyToManyField( ProfessorUser, - on_delete=models.CASCADE, related_name="courses", - verbose_name=_("Professor") + verbose_name=_("Professors") ) thumbnail = models.ImageField(upload_to="courses/thumbnails/", verbose_name=_('Thumbnail')) diff --git a/apps/course/serializers/admin.py b/apps/course/serializers/admin.py index abe3921..2573c3f 100644 --- a/apps/course/serializers/admin.py +++ b/apps/course/serializers/admin.py @@ -122,7 +122,9 @@ class AdminCourseCategorySerializer(serializers.ModelSerializer): class AdminCourseListSerializer(serializers.ModelSerializer): category_name = serializers.SerializerMethodField() - professor_name = serializers.CharField(source='professor.fullname', read_only=True) + professor_name = serializers.SerializerMethodField(read_only=True) + professors = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.all(), many=True, required=False) + professor = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.all(), required=False, allow_null=True, write_only=True) participant_count = serializers.SerializerMethodField() thumbnail = serializers.SerializerMethodField() @@ -135,6 +137,7 @@ class AdminCourseListSerializer(serializers.ModelSerializer): 'category', 'category_name', 'professor', + 'professors', 'professor_name', 'thumbnail', 'is_online', @@ -152,6 +155,9 @@ class AdminCourseListSerializer(serializers.ModelSerializer): 'created_at' ] + def get_professor_name(self, obj): + return ", ".join([p.fullname for p in obj.professors.all()]) + def get_participant_count(self, obj): return obj.participants.count() @@ -171,13 +177,21 @@ class AdminCourseListSerializer(serializers.ModelSerializer): ret['slug'] = get_localized_field(lang, instance.slug) ret['level'] = get_localized_field(lang, instance.level) ret['status'] = get_localized_field(lang, instance.status) + + # Inject legacy fields + first_prof = instance.professors.first() + ret['professor'] = first_prof.id if first_prof else None + ret['professor_name'] = first_prof.fullname if first_prof else "" + ret['professors'] = [p.id for p in instance.professors.all()] return ret class AdminCourseDetailSerializer(serializers.ModelSerializer): slug = serializers.JSONField(required=False, allow_null=True) category_name = serializers.SerializerMethodField() - professor_name = serializers.CharField(source='professor.fullname', read_only=True) + professor_name = serializers.SerializerMethodField(read_only=True) + professors = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.all(), many=True, required=False) + professor = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.all(), required=False, allow_null=True, write_only=True) thumbnail = FileFieldSerializer(required=False, allow_null=True) video_file = FileFieldSerializer(required=False, allow_null=True) participant_count = serializers.SerializerMethodField() @@ -191,6 +205,7 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer): 'category', 'category_name', 'professor', + 'professors', 'professor_name', 'thumbnail', 'video_type', @@ -220,6 +235,9 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'final_price', 'final_price_rub', 'created_at', 'updated_at'] + def get_professor_name(self, obj): + return ", ".join([p.fullname for p in obj.professors.all()]) + def get_participant_count(self, obj): return obj.participants.count() @@ -229,11 +247,34 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer): lang = get_request_lang(self) return get_localized_field(lang, obj.category.name) + def create(self, validated_data): + professor = validated_data.pop('professor', None) + professors = validated_data.pop('professors', None) + instance = super().create(validated_data) + if professors is not None: + instance.professors.set(professors) + elif professor is not None: + instance.professors.set([professor]) + return instance + + def update(self, instance, validated_data): + professor = validated_data.pop('professor', None) + professors = validated_data.pop('professors', None) + instance = super().update(instance, validated_data) + if professors is not None: + instance.professors.set(professors) + elif professor is not None: + instance.professors.set([professor]) + return instance + def to_representation(self, instance): ret = super().to_representation(instance) lang = get_request_lang(self) - # Keep raw JSON values here so the admin form can edit all language variants, - # matching the blog admin flow. + # Inject legacy fields + first_prof = instance.professors.first() + ret['professor'] = first_prof.id if first_prof else None + ret['professor_name'] = first_prof.fullname if first_prof else "" + ret['professors'] = [p.id for p in instance.professors.all()] return ret diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index 4ac1488..b8bffa0 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -176,7 +176,7 @@ class CourseListSerializer(serializers.ModelSerializer): class CourseDetailSerializer(serializers.ModelSerializer): category = CourseCategorySerializer() - professor = serializers.SerializerMethodField() + professors = serializers.SerializerMethodField() thumbnail = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() access = serializers.SerializerMethodField() @@ -209,7 +209,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'category', 'access', 'participant_count', - 'professor', + 'professors', 'is_professor', 'thumbnail', 'video_type', @@ -344,15 +344,14 @@ class CourseDetailSerializer(serializers.ModelSerializer): return self._has_access(user, obj) return False - def get_professor(self, obj): + def get_professors(self, obj): """Return the course professor's profile using UserProfileSerializer""" - if obj.professor: - return UserProfileSerializer(obj.professor, context=self.context).data - return None + return UserProfileSerializer(obj.professors.all(), many=True, context=self.context).data def get_is_professor(self, obj): - if professor := self._get_authenticated_user(): - return obj.professor == professor + if user := self._get_authenticated_user(): + # چک کردن اینکه آیا کاربر در لیست اساتید این دوره هست یا نه + return obj.professors.filter(id=user.id).exists() return False def get_lessons_count(self, obj): @@ -389,7 +388,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): if user.is_staff or user.is_superuser: return True - if course.professor_id == user.id: + if course.professors.filter(id=user.id).exists(): return True return Participant.objects.filter( @@ -601,7 +600,7 @@ class MyCourseListSerializer(serializers.ModelSerializer): return True # اگر کاربر استاد دوره است، دسترسی کامل دارد - if course.professor_id == student.id: + if course.professors.filter(id=student.id).exists(): return True # در غیر این صورت چک می‌کنیم که آیا participant است یا خیر diff --git a/apps/course/signals.py b/apps/course/signals.py index 56567f0..420ef3a 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -20,30 +20,32 @@ UserModel = get_user_model() @receiver(post_save, sender=Course) def handle_room_message_for_course(sender, instance, created, **kwargs): title_str = extract_text_from_json(instance.title) + first_professor = instance.professors.first() if created: # فقط برای موارد جدید اجرا شود RoomMessage.objects.create( name=f"{title_str} - Group", description=f"Group chat for course: {title_str}", - initiator=instance.professor, # استاد به‌عنوان سازنده اتاق + initiator=first_professor, # استاد اول به‌عنوان سازنده اتاق course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ) else: # این بخش در زمان آپدیت دوره اجرا می‌شود # Find the existing group room for this course and update its details + update_kwargs = { + 'name': f"{title_str} - Group", + 'description': f"Group chat for course: {title_str}", + } + if first_professor: + update_kwargs['initiator'] = first_professor RoomMessage.objects.filter( course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP - ).update( - name=f"{title_str} - Group", - description=f"Group chat for course: {title_str}", - initiator=instance.professor - ) + ).update(**update_kwargs) @receiver(post_save, sender=Course) def ensure_professor_role(sender, instance, **kwargs): - professor = getattr(instance, 'professor', None) - if professor: + for professor in instance.professors.all(): professor.ensure_professor_profile() @receiver([post_save, post_delete], sender=Course) @@ -52,9 +54,8 @@ def invalidate_professor_course_cache(sender, instance, **kwargs): Clears the cached professor detail page AND their course list whenever a course assigned to them is created, updated, or deleted. """ - if instance.professor: - detail_cache_key = f"professor_detail_{instance.professor.slug}" - + for professor in instance.professors.all(): + detail_cache_key = f"professor_detail_{professor.slug}" cache.delete(detail_cache_key) # Optional: If you update a Professor's profile in the admin directly @@ -76,17 +77,18 @@ def sync_course_chat_locks(sender, instance, **kwargs): room_type=RoomMessage.RoomTypeChoices.GROUP ).update(is_locked=instance.is_group_chat_locked) - # 2. Update the Private Chats between the Professor and Students of this course + # 2. Update the Private Chats between the Professors and Students of this course # Get all student IDs enrolled in this course student_ids = instance.participants.values_list('student_id', flat=True) + professor_ids = list(instance.professors.values_list('id', flat=True)) - if student_ids: + if student_ids and professor_ids: RoomMessage.objects.filter( room_type=RoomMessage.RoomTypeChoices.PRIVATE ).filter( - # Find rooms where initiator is prof and recipient is student, OR vice versa - Q(initiator_id=instance.professor_id, recipient_id__in=student_ids) | - Q(initiator_id__in=student_ids, recipient_id=instance.professor_id) + # Find rooms where initiator is any professor and recipient is student, OR vice versa + Q(initiator_id__in=professor_ids, recipient_id__in=student_ids) | + Q(initiator_id__in=student_ids, recipient_id__in=professor_ids) ).update(is_locked=instance.is_professor_chat_locked) diff --git a/apps/course/views/admin.py b/apps/course/views/admin.py index 2b42ef3..eefce15 100644 --- a/apps/course/views/admin.py +++ b/apps/course/views/admin.py @@ -65,11 +65,11 @@ class AdminCourseViewSet(viewsets.ModelViewSet): return AdminCourseDetailSerializer def get_queryset(self): - queryset = Course.objects.all().select_related('category', 'professor') + queryset = Course.objects.all().select_related('category').prefetch_related('professors') # Professors can only see their own courses if is_professor(self.request): - queryset = queryset.filter(professor_id=self.request.user.id) + queryset = queryset.filter(professors=self.request.user) # Search Query search_query = self.request.query_params.get('search', None) @@ -120,7 +120,7 @@ class AdminCourseViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): # If the logged-in user is a professor, force the professor field to themselves if is_professor(self.request): - serializer.save(professor_id=self.request.user.id) + serializer.save(professor=self.request.user) else: serializer.save() @@ -128,9 +128,9 @@ class AdminCourseViewSet(viewsets.ModelViewSet): # Prevent professors from reassigning a course to another professor if is_professor(self.request): instance = self.get_object() - if instance.professor_id != self.request.user.id: + if not instance.professors.filter(id=self.request.user.id).exists(): raise PermissionDenied("You can only edit your own courses.") - serializer.save(professor_id=self.request.user.id) + serializer.save(professor=self.request.user) else: serializer.save() @@ -150,7 +150,7 @@ class AdminChapterViewSet(viewsets.ModelViewSet): # Professors can only see chapters of their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) course_id = self.request.query_params.get('course', None) if course_id: @@ -190,7 +190,7 @@ class AdminCourseLessonViewSet(viewsets.ModelViewSet): # Professors can only see lessons of their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) course_id = self.request.query_params.get('course', None) if course_id: @@ -242,7 +242,7 @@ class AdminCourseAttachmentViewSet(viewsets.ModelViewSet): # Professors can only see attachments of their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) course_id = self.request.query_params.get('course', None) if course_id: @@ -258,7 +258,7 @@ class AdminCourseAttachmentViewSet(viewsets.ModelViewSet): return super().create(request, *args, **kwargs) if is_professor(request): - course = Course.objects.filter(id=course_id, professor_id=request.user.id).first() + course = Course.objects.filter(id=course_id, professors=request.user).first() else: course = Course.objects.filter(id=course_id).first() @@ -342,7 +342,7 @@ class AdminCourseGlossaryViewSet(viewsets.ModelViewSet): # Professors can only see glossaries of their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) course_id = self.request.query_params.get('course', None) if course_id: @@ -358,7 +358,7 @@ class AdminCourseGlossaryViewSet(viewsets.ModelViewSet): return super().create(request, *args, **kwargs) if is_professor(request): - course = Course.objects.filter(id=course_id, professor_id=request.user.id).first() + course = Course.objects.filter(id=course_id, professors=request.user).first() else: course = Course.objects.filter(id=course_id).first() @@ -547,7 +547,7 @@ class AdminLiveSessionViewSet(viewsets.ModelViewSet): # Professors can only see live sessions of their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) search = self.request.query_params.get('search', None) if search: diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 8dafbb1..5007f79 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -97,7 +97,7 @@ from utils.pagination import StandardResultsSetPagination class CourseListAPIView(ListAPIView): serializer_class = CourseListSerializer filter_backends = [SearchFilter] - search_fields = ['title', 'category__name', 'professor__fullname'] + search_fields = ['title', 'category__name', 'professors__fullname'] pagination_class = StandardResultsSetPagination permission_classes = [AllowAny] authentication_classes = [TokenAuthentication] @@ -148,7 +148,8 @@ class CourseListAPIView(ListAPIView): """ queryset = Course.objects.select_related( 'category', - 'professor' + ).prefetch_related( + 'professors', ).exclude( status__contains=[{"title": Course.StatusChoices.INACTIVE}] ).order_by('-created_at', '-id') @@ -212,8 +213,8 @@ class CourseDetailAPIView(RetrieveAPIView): """ return Course.objects.select_related( 'category', - 'professor' ).prefetch_related( + 'professors', 'chapters__lessons__lesson', 'chapters__lessons__completions', 'attachments__attachment', @@ -272,8 +273,8 @@ class MyCourseListAPIView(ListAPIView): """ queryset = Course.objects.select_related( 'category', - 'professor' ).prefetch_related( + 'professors', 'chapters__lessons__lesson', 'chapters__lessons__completions', 'participants__student' @@ -283,8 +284,8 @@ class MyCourseListAPIView(ListAPIView): filters = request.query_params user = self.request.user - # Include courses where user is a student OR the professor - qs = queryset.filter(Q(participants__student=user) | Q(professor=user)).distinct() + # Include courses where user is a student OR one of the professors + qs = queryset.filter(Q(participants__student=user) | Q(professors=user)).distinct() completed_param = filters.get('completed') if completed_param is not None: @@ -485,7 +486,7 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): @staticmethod def _user_has_access(user, course: Course) -> bool: - if user.is_staff or course.professor_id == user.id: + if user.is_staff or course.professors.filter(id=user.id).exists(): return True return Participant.objects.filter(course=course, student=user).exists() diff --git a/apps/course/views/professor.py b/apps/course/views/professor.py index ee514d6..14a8cce 100644 --- a/apps/course/views/professor.py +++ b/apps/course/views/professor.py @@ -198,8 +198,9 @@ class ProfessorCourseListAPIView(ListAPIView): 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( + return Course.objects.select_related('category').prefetch_related( + 'professors', 'lessons__lesson', 'lessons__completions', 'participants__student', - ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}]).filter(professor=professor) + ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}]).filter(professors=professor) diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py index dfe1f70..2dcbf5b 100644 --- a/apps/quiz/admin/quiz.py +++ b/apps/quiz/admin/quiz.py @@ -41,7 +41,7 @@ class QuizAdmin(ModelAdmin): # اولویت دوم: professor - فقط کوئیزهای دوره‌های خود if request.user.has_role('professor'): - return queryset.filter(lesson__course__professor=request.user) + return queryset.filter(lesson__course__professors=request.user) return queryset.none() @@ -104,7 +104,7 @@ class QuizAdmin(ModelAdmin): 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) + form.base_fields['lesson'].queryset = CourseLesson.objects.filter(course__professors=request.user) else: # سایر کاربران - عدم دسترسی form.base_fields['lesson'].queryset = CourseLesson.objects.none() diff --git a/apps/quiz/views/admin.py b/apps/quiz/views/admin.py index fd1a7f4..fef819a 100644 --- a/apps/quiz/views/admin.py +++ b/apps/quiz/views/admin.py @@ -102,7 +102,7 @@ class AdminQuizViewSet(ModelViewSet): # Professors can only see quizzes of their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) # Handle Search search_query = self.request.query_params.get('search', None) @@ -216,7 +216,7 @@ class AdminQuestionViewSet(ModelViewSet): # Professors can only see questions of their own courses' quizzes if is_professor(self.request): - queryset = queryset.filter(quiz__course__professor_id=self.request.user.id) + queryset = queryset.filter(quiz__course__professors=self.request.user) quiz_id = self.request.query_params.get('quiz_id', None) if not quiz_id: @@ -270,7 +270,7 @@ class AdminCourseListView(ListAPIView): def get_queryset(self): queryset = Course.objects.all().order_by('-id') if is_professor(self.request): - queryset = queryset.filter(professor_id=self.request.user.id) + queryset = queryset.filter(professors=self.request.user) return queryset @@ -293,6 +293,6 @@ class AdminLessonListView(ListAPIView): # Professors only see lessons of their own courses if is_professor(self.request): - qs = qs.filter(course__professor_id=self.request.user.id) + qs = qs.filter(course__professors=self.request.user) return qs diff --git a/apps/transaction/views.py b/apps/transaction/views.py index e0df7c1..66d6a61 100644 --- a/apps/transaction/views.py +++ b/apps/transaction/views.py @@ -487,7 +487,7 @@ class AdminTransactionViewSet(ModelViewSet): # Professors can only see transactions for their own courses if is_professor(self.request): - queryset = queryset.filter(course__professor_id=self.request.user.id) + queryset = queryset.filter(course__professors=self.request.user) # Search search_query = self.request.query_params.get('search', None)