Browse Source

feat: implement user authentication, password management, and course participant views with encryption options

master
Mohsen Taba 2 months ago
parent
commit
6930cdc233
  1. 55
      apps/account/management/commands/set_default_passwords.py
  2. 18
      apps/account/migrations/0005_user_password_enc.py
  3. 42
      apps/account/models/user.py
  4. 11
      apps/account/serializers/user.py
  5. 8
      apps/account/views/user.py
  6. 7
      apps/course/views/participant.py
  7. 17
      apps/quiz/migrations/0004_alter_question_options.py
  8. 11
      nginx/imamjavad.conf
  9. 1
      requirements.txt

55
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."))

18
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'),
),
]

42
apps/account/models/user.py

@ -1,5 +1,9 @@
import random import random
import secrets import secrets
import base64
import hashlib
from cryptography.fernet import Fernet
from django.conf import settings
from dj_language.field import LanguageField from dj_language.field import LanguageField
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
@ -74,6 +78,15 @@ class User(AbstractUser):
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_('deleted at')) deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_('deleted at'))
info = models.TextField(verbose_name=_("Info"), null=True, blank=True) info = models.TextField(verbose_name=_("Info"), null=True, blank=True)
skill = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('skill')) 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() objects = UserManager()
@ -288,6 +301,35 @@ class User(AbstractUser):
return slug[:255] 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: class Meta:
ordering = ("-id",) ordering = ("-id",)

11
apps/account/serializers/user.py

@ -227,11 +227,18 @@ class AdminUserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) password = serializers.CharField(write_only=True, required=False, validators=[validate_password])
email = serializers.EmailField(required=True) email = serializers.EmailField(required=True)
auth_token = serializers.SerializerMethodField() auth_token = serializers.SerializerMethodField()
plain_password = serializers.SerializerMethodField()
def get_auth_token(self, obj): def get_auth_token(self, obj):
token = Token.objects.filter(user=obj).first() token = Token.objects.filter(user=obj).first()
return token.key if token else None 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: class Meta:
model = User model = User
fields = [ fields = [
@ -239,7 +246,7 @@ class AdminUserSerializer(serializers.ModelSerializer):
'gender', 'birthdate', 'info', 'skill', 'city', 'country', 'gender', 'birthdate', 'info', 'skill', 'city', 'country',
'device_id', 'device_os', 'user_agent', 'client_ip', 'fcm', 'device_id', 'device_os', 'user_agent', 'client_ip', 'fcm',
'user_type', 'is_active', 'is_staff', 'is_superuser', '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'] 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) user = User(**validated_data)
if password: if password:
user.set_password(password) user.set_password(password)
user.set_plain_password(password)
else: else:
user.set_unusable_password() user.set_unusable_password()
user.save() user.save()
@ -258,5 +266,6 @@ class AdminUserSerializer(serializers.ModelSerializer):
user = super().update(instance, validated_data) user = super().update(instance, validated_data)
if password: if password:
user.set_password(password) user.set_password(password)
user.set_plain_password(password)
user.save() user.save()
return user return user

8
apps/account/views/user.py

@ -691,6 +691,14 @@ class AdminUserViewSet(ModelViewSet):
if user_type_param: if user_type_param:
queryset = queryset.filter(user_type=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 # Handle Ordering
ordering_param = self.request.query_params.get('ordering', '-date_joined') ordering_param = self.request.query_params.get('ordering', '-date_joined')
allowed_orderings = [ allowed_orderings = [

7
apps/course/views/participant.py

@ -28,7 +28,8 @@ class CourseParticipantsView(generics.ListAPIView):
def get_queryset(self): def get_queryset(self):
""" """
Optimized queryset with select_related for course relationship. 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') course_slug = self.kwargs.get('slug')
try: try:
@ -36,14 +37,12 @@ class CourseParticipantsView(generics.ListAPIView):
except Course.DoesNotExist: except Course.DoesNotExist:
raise AppAPIException({'message': "Course not found"}) raise AppAPIException({'message': "Course not found"})
# 👇 Apply the strict filters for Normal Users only
return StudentUser.objects.select_related().filter( return StudentUser.objects.select_related().filter(
participated_courses__course=course, participated_courses__course=course,
is_active=True, # Exclude soft-deleted users
email__isnull=False # Exclude guest users email__isnull=False # Exclude guest users
).exclude( ).exclude(
email__exact='' # Extra safety just in case an email is a blank string email__exact='' # Extra safety just in case an email is a blank string
)
).distinct()

17
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'},
),
]

11
nginx/imamjavad.conf

@ -11,8 +11,8 @@ server {
listen 443 ssl; listen 443 ssl;
server_name imamjavad.online imamjavad.nwhco.ir imamjavad.newhorizonco.uk; 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; include /etc/nginx/options-ssl-nginx.conf;
# ========== Global Settings ========== # ========== Global Settings ==========
@ -179,8 +179,9 @@ server {
server_name dashboard.imamjavad.online; 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; include /etc/nginx/options-ssl-nginx.conf;
client_max_body_size 500M; client_max_body_size 500M;
@ -194,6 +195,8 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
client_max_body_size 1200M; client_max_body_size 1200M;
} }

1
requirements.txt

@ -125,6 +125,7 @@ geoip2==4.7.0
# firebase-admin==6.2.0 # firebase-admin==6.2.0
google-auth==2.6.0 google-auth==2.6.0
pyjwt 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/django-limitless-dashboard.git/archive/master.zip
https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip

Loading…
Cancel
Save