From 466275525658726c6c4dc80a68c05615c7a47984 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 25 May 2026 09:05:34 +0330 Subject: [PATCH] views of user page for admin panel added --- apps/account/serializers/user.py | 33 +++++ apps/account/urls.py | 7 + apps/account/views/user.py | 123 +++++++++++++++++- .../redistribute_lessons_to_chapters.py | 117 +++++++++++++++++ entrypoint.sh | 2 +- 5 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 apps/course/management/commands/redistribute_lessons_to_chapters.py diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index 90d07ce..cfc884f 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -220,3 +220,36 @@ class UserFCMSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['fcm'] + + +class AdminUserSerializer(serializers.ModelSerializer): + avatar = FileFieldSerializer(required=False) + password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) + email = serializers.EmailField(required=True) + + class Meta: + model = User + fields = [ + 'id', 'fullname', 'email', 'phone_number', 'password', 'avatar', + 'gender', 'user_type', 'is_active', 'is_staff', 'is_superuser', + 'date_joined', 'last_login' + ] + read_only_fields = ['id', 'date_joined', 'last_login'] + + def create(self, validated_data): + password = validated_data.pop('password', None) + user = User(**validated_data) + if password: + user.set_password(password) + else: + user.set_unusable_password() + user.save() + return user + + def update(self, instance, validated_data): + password = validated_data.pop('password', None) + user = super().update(instance, validated_data) + if password: + user.set_password(password) + user.save() + return user diff --git a/apps/account/urls.py b/apps/account/urls.py index a45b350..c64f3cd 100644 --- a/apps/account/urls.py +++ b/apps/account/urls.py @@ -7,6 +7,9 @@ from apps.account import views from apps.geolocation_package.views.geolocation import IPGeolocationAPIView, ReverseGeolocationAPIView from apps.geolocation_package.views.region_info import RegionInfoView +admin_router = DefaultRouter() +admin_router.register(r'users', views.AdminUserViewSet, basename='admin-users') + urlpatterns = [ path('location-info/', IPGeolocationAPIView.as_view(), name='location-info'), path('location-info/reverse/', ReverseGeolocationAPIView.as_view(), name='reverse-location-info'), @@ -44,4 +47,8 @@ urlpatterns = [ path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), path('update-fcm/', views.UpdateFCMView.as_view(), name='update-fcm'), + #admin panel : + path('admin/login/', views.AdminLoginView.as_view(), name='admin-login'), + path('admin/', include(admin_router.urls)), + ] \ No newline at end of file diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 5c166a7..2d3535d 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -23,7 +23,7 @@ from rest_framework.exceptions import ValidationError from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException from apps.account.models import User -from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer,UserFCMSerializer,WebUserGuestSerializer +from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer,UserFCMSerializer,WebUserGuestSerializer, AdminUserSerializer from apps.account.serializers.user_web import WebUserRegisterSerializer from utils.redis import RedisManager from utils.exceptions import AppAPIException @@ -555,4 +555,123 @@ class UpdateFCMView(GenericAPIView): user.fcm = fcm_token user.save() - return Response({"detail": "FCM token updated successfully."}, status=status.HTTP_200_OK) \ No newline at end of file + return Response({"detail": "FCM token updated successfully."}, status=status.HTTP_200_OK) + + +class AdminLoginView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserLoginSerializer + + @swagger_auto_schema( + operation_description="Login specifically for Admin Panel users", + request_body=UserLoginSerializer, + ) + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + def get_client_ip(self): + request = self.request + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + + email = request.data['email'] + try: + user_obj = User.objects.get(email=email) + except User.DoesNotExist: + raise ValidationError({"email": "No admin found with that information."}) + + # --- THE CRUCIAL ADMIN CHECK --- + # Adjust 'is_staff' or 'is_superuser' based on how you define admins in Django + if not (user_obj.is_staff or user_obj.is_superuser): + raise AuthenticationFailed("No admin found with that information.") + + user = authenticate(request, username=email, password=data['password']) + if not user: + raise ValidationError({"password": "Password is incorrect"}) + + user_timezone = serializer.validated_data.pop('timezone', None) + user.last_login = timezone.now() + user.is_active = True + user.save() + + token, created = Token.objects.get_or_create(user=user) + + # Log the history + user.login_history.create( + ip=self.get_client_ip(), + timezone=user_timezone, + device_os='web_admin' + ) + + return Response({ + "id": user.id, + "fullname": user.fullname, + "email": user.email, + "token": token.key, + "user_type": user.user_type_based_on_groups, + "avatar": request.build_absolute_uri(user.avatar.url) if user.avatar else None, + }, status=status.HTTP_201_CREATED) + + +from rest_framework.viewsets import ModelViewSet +from rest_framework.filters import SearchFilter, OrderingFilter +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.permissions import IsAdminUser + +class AdminUserViewSet(ModelViewSet): + """ + Admin-only endpoint for full CRUD operations on Users. + Includes searching, filtering, and pagination. + """ + serializer_class = AdminUserSerializer + permission_classes = [IsAuthenticated, IsAdminUser] # Strictly admin only + authentication_classes = [TokenAuthentication] + filter_backends = [] # Disable default backend filtering to use custom manual filtering + + def get_queryset(self): + queryset = User.objects.filter(is_active=True, email__isnull=False).exclude(email='') + + # Handle Search + search_query = self.request.query_params.get('search', None) + if search_query: + queryset = queryset.filter( + Q(fullname__icontains=search_query) | + Q(email__icontains=search_query) | + Q(phone_number__icontains=search_query) + ) + + # Handle is_active filter + is_active_param = self.request.query_params.get('is_active', None) + if is_active_param is not None: + if is_active_param.lower() == 'true': + queryset = queryset.filter(is_active=True) + elif is_active_param.lower() == 'false': + queryset = queryset.filter(is_active=False) + + # Handle gender filter + gender_param = self.request.query_params.get('gender', None) + if gender_param: + queryset = queryset.filter(gender=gender_param) + + # Handle Ordering + ordering_param = self.request.query_params.get('ordering', '-date_joined') + allowed_orderings = [ + 'date_joined', '-date_joined', + 'last_login', '-last_login', + 'fullname', '-fullname' + ] + if ordering_param in allowed_orderings: + queryset = queryset.order_by(ordering_param) + else: + queryset = queryset.order_by('-date_joined') + + return queryset.distinct() \ No newline at end of file diff --git a/apps/course/management/commands/redistribute_lessons_to_chapters.py b/apps/course/management/commands/redistribute_lessons_to_chapters.py new file mode 100644 index 0000000..b41176a --- /dev/null +++ b/apps/course/management/commands/redistribute_lessons_to_chapters.py @@ -0,0 +1,117 @@ +import random +import sys +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.course.models import Course, CourseChapter, CourseLesson + +class Command(BaseCommand): + help = 'Redistributes course lessons into multiple chapters randomly (maintaining order) for courses with >= 2 lessons.' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Simulates the redistribution without modifying the database.', + ) + parser.add_argument( + '--max-chapters', + type=int, + default=5, + help='Maximum number of chapters to create for a course (default: 5).', + ) + + def handle(self, *args, **options): + # Reconfigure stdout to UTF-8 to prevent UnicodeEncodeError on Windows terminals + try: + sys.stdout.reconfigure(encoding='utf-8') + except AttributeError: + pass + + dry_run = options['dry_run'] + max_chapters = options['max_chapters'] + + if dry_run: + self.stdout.write(self.style.WARNING("⚠️ DRY RUN MODE: No database changes will be saved.")) + + self.stdout.write(self.style.WARNING("Starting lesson redistribution...")) + + courses = Course.objects.all() + courses_updated = 0 + total_chapters_created = 0 + total_lessons_redistributed = 0 + + # Wrap everything in an atomic transaction to ensure safety + with transaction.atomic(): + for course in courses: + lessons = list(course.lessons.all().order_by('priority')) + n = len(lessons) + + if n < 2: + self.stdout.write(self.style.NOTICE( + f"Skipping course: '{course.title}' (ID: {course.id}) - Only has {n} lesson(s)." + )) + continue + + # Number of chapters C must be between 2 and min(N, max_chapters) + c = random.randint(2, min(n, max_chapters)) + + self.stdout.write(self.style.MIGRATE_HEADING( + f"\nReorganizing Course: '{course.title}' (ID: {course.id})" + f"\n Total lessons: {n} | Splitting into {c} chapters..." + )) + + # Partition the ordered lessons list into C slices + # We randomly pick C-1 unique split boundaries between 1 and n-1 + split_points = sorted(random.sample(range(1, n), c - 1)) + + partitioned_lessons = [] + last_idx = 0 + for sp in split_points: + partitioned_lessons.append(lessons[last_idx:sp]) + last_idx = sp + partitioned_lessons.append(lessons[last_idx:]) + + if not dry_run: + # 1. Unlink lessons from chapters first to prevent CASCADE deletion. + # Using .update() bypasses the save() method and avoids AttributeErrors + # caused by _adjust_priorities when chapter is None. + course.lessons.all().update(chapter=None) + + # 2. Delete all existing chapters for this course + course.chapters.all().delete() + + # 3. Create the new chapters and assign the sliced lessons + for i, chapter_lessons in enumerate(partitioned_lessons): + chapter_title = f"فصل {i+1}" + priority = i + 1 + + self.stdout.write(f" 📂 Chapter '{chapter_title}' (priority {priority}) contains:") + for idx, lesson in enumerate(chapter_lessons): + self.stdout.write(f" - [{idx + 1}] {lesson.title} (Original ID: {lesson.id})") + + if not dry_run: + # Create the new chapter + chapter = CourseChapter.objects.create( + course=course, + title=chapter_title, + priority=priority + ) + + # Assign and save each lesson within this chapter + for idx, lesson in enumerate(chapter_lessons): + lesson.chapter = chapter + lesson.priority = idx + 1 + lesson.save() + + total_chapters_created += 1 + total_lessons_redistributed += len(chapter_lessons) + + courses_updated += 1 + + if dry_run: + self.stdout.write(self.style.WARNING("\n⚠️ Dry run complete. No database changes were made.")) + else: + self.stdout.write(self.style.SUCCESS( + f"\n🎉 Successfully redistributed {total_lessons_redistributed} lessons " + f"across {total_chapters_created} chapters in {courses_updated} courses!" + )) diff --git a/entrypoint.sh b/entrypoint.sh index 3989ad6..3be9777 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,7 +3,7 @@ sleep 20 python manage.py makemigrations python manage.py migrate # python manage.py seed_images -python manage.py compilemessages +# python manage.py compilemessages python manage.py collectstatic --noinput # Seed Russian data (only runs once, skips if data exists) # python manage.py seed_corrections