From 6930cdc2331ff7c29387d2324d1249826cd57952 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 1 Jun 2026 15:59:39 +0330 Subject: [PATCH] feat: implement user authentication, password management, and course participant views with encryption options --- .../commands/set_default_passwords.py | 55 +++++++++++++++++++ .../migrations/0005_user_password_enc.py | 18 ++++++ apps/account/models/user.py | 42 ++++++++++++++ apps/account/serializers/user.py | 11 +++- apps/account/views/user.py | 8 +++ apps/course/views/participant.py | 9 ++- .../migrations/0004_alter_question_options.py | 17 ++++++ nginx/imamjavad.conf | 11 ++-- requirements.txt | 1 + 9 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 apps/account/management/commands/set_default_passwords.py create mode 100644 apps/account/migrations/0005_user_password_enc.py create mode 100644 apps/quiz/migrations/0004_alter_question_options.py diff --git a/apps/account/management/commands/set_default_passwords.py b/apps/account/management/commands/set_default_passwords.py new file mode 100644 index 0000000..3028c33 --- /dev/null +++ b/apps/account/management/commands/set_default_passwords.py @@ -0,0 +1,55 @@ +import secrets +from django.core.management.base import BaseCommand +from django.db.models import Q +from apps.account.models import User + + +class Command(BaseCommand): + help = 'Sets a new plain/hashed password and encrypts it for active users who lack an encrypted password.' + + def add_arguments(self, parser): + parser.add_argument( + '--password', + type=str, + help='Specific password to set for all matched users. If not provided, a random 8-character password will be generated for each user.' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Run the command without saving changes to the database.' + ) + + def handle(self, *args, **options): + password_input = options['password'] + dry_run = options['dry_run'] + + # Find active users without an encrypted password + users = User.objects.filter( + Q(password_enc='') | Q(password_enc__isnull=True), + is_active=True + ) + + if not users.exists(): + self.stdout.write(self.style.SUCCESS("No active users found without an encrypted password.")) + return + + self.stdout.write(f"Found {users.count()} active users without an encrypted password.") + + updated_count = 0 + for user in users: + # Generate or use the provided password + new_password = password_input if password_input else secrets.token_urlsafe(6)[:8] + + self.stdout.write(f"User: {user.email or user.fullname} (ID: {user.id}) -> Password: {new_password}") + + if not dry_run: + user.set_password(new_password) + user.set_plain_password(new_password) + user.save() + + updated_count += 1 + + if dry_run: + self.stdout.write(self.style.WARNING(f"[DRY-RUN] Would have updated {updated_count} users.")) + else: + self.stdout.write(self.style.SUCCESS(f"Successfully updated {updated_count} users.")) diff --git a/apps/account/migrations/0005_user_password_enc.py b/apps/account/migrations/0005_user_password_enc.py new file mode 100644 index 0000000..6ebb913 --- /dev/null +++ b/apps/account/migrations/0005_user_password_enc.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-06-01 15:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0004_consultantuser_alter_user_user_type'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='password_enc', + field=models.CharField(blank=True, default='', help_text='Encrypted copy of password for displaying in the admin panel.', max_length=512, verbose_name='Encrypted Password'), + ), + ] diff --git a/apps/account/models/user.py b/apps/account/models/user.py index ce3864c..1bebcd6 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -1,5 +1,9 @@ import random import secrets +import base64 +import hashlib +from cryptography.fernet import Fernet +from django.conf import settings from dj_language.field import LanguageField from django.contrib.auth.models import AbstractUser from django.db import models @@ -74,6 +78,15 @@ class User(AbstractUser): deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_('deleted at')) info = models.TextField(verbose_name=_("Info"), null=True, blank=True) skill = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('skill')) + password_enc = models.CharField( + max_length=512, + blank=True, + default='', + verbose_name=_("Encrypted Password"), + help_text=_( + "Encrypted copy of password for displaying in the admin panel." + ) + ) objects = UserManager() @@ -287,6 +300,35 @@ class User(AbstractUser): counter += 1 return slug[:255] + + def _get_fernet(self): + key_bytes = settings.SECRET_KEY.encode('utf-8') + hashed_key = hashlib.sha256(key_bytes).digest() + fernet_key = base64.urlsafe_b64encode(hashed_key) + return Fernet(fernet_key) + + def set_plain_password(self, plain_password): + if not plain_password: + self.password_enc = '' + return + try: + password_bytes = plain_password.encode('utf-8') + fernet = self._get_fernet() + encrypted_token = fernet.encrypt(password_bytes) + self.password_enc = encrypted_token.decode('utf-8') + except Exception: + self.password_enc = '' + + def get_plain_password(self): + if not self.password_enc: + return '' + try: + encrypted_bytes = self.password_enc.encode('utf-8') + fernet = self._get_fernet() + decrypted_bytes = fernet.decrypt(encrypted_bytes) + return decrypted_bytes.decode('utf-8') + except Exception: + return '' class Meta: diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index f8187b7..7aa69df 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -227,11 +227,18 @@ class AdminUserSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) email = serializers.EmailField(required=True) auth_token = serializers.SerializerMethodField() + plain_password = serializers.SerializerMethodField() def get_auth_token(self, obj): token = Token.objects.filter(user=obj).first() return token.key if token else None + def get_plain_password(self, obj): + request = self.context.get('request') + if request and request.user and (request.user.is_superuser or request.user.user_type in ['super_admin', 'admin']): + return obj.get_plain_password() + return None + class Meta: model = User fields = [ @@ -239,7 +246,7 @@ class AdminUserSerializer(serializers.ModelSerializer): 'gender', 'birthdate', 'info', 'skill', 'city', 'country', 'device_id', 'device_os', 'user_agent', 'client_ip', 'fcm', 'user_type', 'is_active', 'is_staff', 'is_superuser', - 'date_joined', 'last_login', 'auth_token' + 'date_joined', 'last_login', 'auth_token', 'plain_password' ] read_only_fields = ['id', 'date_joined', 'last_login', 'auth_token', 'device_id', 'device_os', 'user_agent', 'client_ip'] @@ -248,6 +255,7 @@ class AdminUserSerializer(serializers.ModelSerializer): user = User(**validated_data) if password: user.set_password(password) + user.set_plain_password(password) else: user.set_unusable_password() user.save() @@ -258,5 +266,6 @@ class AdminUserSerializer(serializers.ModelSerializer): user = super().update(instance, validated_data) if password: user.set_password(password) + user.set_plain_password(password) user.save() return user diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 6bb739b..8970415 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -691,6 +691,14 @@ class AdminUserViewSet(ModelViewSet): if user_type_param: queryset = queryset.filter(user_type=user_type_param) + # Handle course filter for selecting students enrolled in a specific course + course_param = self.request.query_params.get('course', None) + if course_param: + queryset = queryset.filter( + participated_courses__course_id=course_param, + participated_courses__is_active=True + ) + # Handle Ordering ordering_param = self.request.query_params.get('ordering', '-date_joined') allowed_orderings = [ diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py index 58e3860..841c48d 100644 --- a/apps/course/views/participant.py +++ b/apps/course/views/participant.py @@ -28,7 +28,8 @@ class CourseParticipantsView(generics.ListAPIView): def get_queryset(self): """ Optimized queryset with select_related for course relationship. - Filters out guest users (no email) and soft-deleted users (is_active=False). + Filters out guest users (no email) while keeping inactive students visible + for the admin course participants table. """ course_slug = self.kwargs.get('slug') try: @@ -36,14 +37,12 @@ class CourseParticipantsView(generics.ListAPIView): except Course.DoesNotExist: raise AppAPIException({'message': "Course not found"}) - # 👇 Apply the strict filters for Normal Users only return StudentUser.objects.select_related().filter( participated_courses__course=course, - is_active=True, # Exclude soft-deleted users email__isnull=False # Exclude guest users ).exclude( email__exact='' # Extra safety just in case an email is a blank string - ) + ).distinct() @@ -74,4 +73,4 @@ class CourseParticipantsView(generics.ListAPIView): # ) # serializer = self.get_serializer(participant) -# return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file +# return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apps/quiz/migrations/0004_alter_question_options.py b/apps/quiz/migrations/0004_alter_question_options.py new file mode 100644 index 0000000..9aa00e0 --- /dev/null +++ b/apps/quiz/migrations/0004_alter_question_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.12 on 2026-06-01 15:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('quiz', '0003_quiz_course'), + ] + + operations = [ + migrations.AlterModelOptions( + name='question', + options={'ordering': ('priority', 'id'), 'verbose_name': 'Question', 'verbose_name_plural': 'Questions'}, + ), + ] diff --git a/nginx/imamjavad.conf b/nginx/imamjavad.conf index 04aa0f9..a58a016 100644 --- a/nginx/imamjavad.conf +++ b/nginx/imamjavad.conf @@ -11,8 +11,8 @@ server { listen 443 ssl; server_name imamjavad.online imamjavad.nwhco.ir imamjavad.newhorizonco.uk; - ssl_certificate /etc/nginx/certs/nwhco.pem; - ssl_certificate_key /etc/nginx/certs/nwhco.key; + ssl_certificate /etc/nginx/letsencrypt/live/imamjavad.online/fullchain.pem; + ssl_certificate_key /etc/nginx/letsencrypt/live/imamjavad.online/privkey.pem; include /etc/nginx/options-ssl-nginx.conf; # ========== Global Settings ========== @@ -179,8 +179,9 @@ server { server_name dashboard.imamjavad.online; - ssl_certificate /etc/nginx/certs/nwhco.pem; - ssl_certificate_key /etc/nginx/certs/nwhco.key; + ssl_certificate /etc/nginx/letsencrypt/live/imamjavad.online/fullchain.pem; + ssl_certificate_key /etc/nginx/letsencrypt/live/imamjavad.online/privkey.pem; + include /etc/nginx/options-ssl-nginx.conf; client_max_body_size 500M; @@ -194,6 +195,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; client_max_body_size 1200M; } diff --git a/requirements.txt b/requirements.txt index f250300..abe5046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -125,6 +125,7 @@ geoip2==4.7.0 # firebase-admin==6.2.0 google-auth==2.6.0 pyjwt +cryptography>=41.0.0 https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip