You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
474 lines
18 KiB
474 lines
18 KiB
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.password_validation import validate_password
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework import serializers
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
|
from phonenumber_field.serializerfields import PhoneNumberField
|
|
from drf_spectacular.utils import extend_schema_field, OpenApiTypes
|
|
from apps.account.models.verification import VerificationDocument
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
|
|
class RegisterRequestSerializer(serializers.Serializer):
|
|
email = serializers.EmailField(required=True)
|
|
password = serializers.CharField(write_only=True, required=True, min_length=6)
|
|
first_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
|
|
last_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
|
|
fullname = serializers.CharField(required=False, allow_blank=True, max_length=255)
|
|
phone_number = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
|
user_type = serializers.ChoiceField(
|
|
choices=User.UserType.choices,
|
|
default=User.UserType.CLIENT,
|
|
required=False
|
|
)
|
|
languages = serializers.ListField(
|
|
child=serializers.CharField(max_length=10),
|
|
required=False,
|
|
default=list
|
|
)
|
|
skills = serializers.ListField(
|
|
child=serializers.CharField(max_length=100),
|
|
required=False,
|
|
default=list
|
|
)
|
|
country = serializers.CharField(required=False, allow_blank=True, max_length=255)
|
|
city = serializers.CharField(required=False, allow_blank=True, max_length=255)
|
|
|
|
def validate_email(self, value):
|
|
normalized = value.strip().lower()
|
|
if User.objects.filter(email__iexact=normalized).exists():
|
|
raise serializers.ValidationError("A user with this email address already exists.")
|
|
return normalized
|
|
|
|
def validate_password(self, value):
|
|
validate_password(value)
|
|
return value
|
|
|
|
def validate(self, attrs):
|
|
fn = attrs.get('first_name', '').strip()
|
|
ln = attrs.get('last_name', '').strip()
|
|
fl = attrs.get('fullname', '').strip()
|
|
|
|
if fn or ln:
|
|
attrs['first_name'] = fn
|
|
attrs['last_name'] = ln
|
|
if not fl:
|
|
attrs['fullname'] = f"{fn} {ln}".strip()
|
|
else:
|
|
attrs['fullname'] = fl
|
|
elif fl:
|
|
parts = fl.split(maxsplit=1)
|
|
attrs['first_name'] = parts[0]
|
|
attrs['last_name'] = parts[1] if len(parts) > 1 else ""
|
|
attrs['fullname'] = fl
|
|
else:
|
|
raise serializers.ValidationError({"first_name": "Please provide first_name and last_name (or full name)."})
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
email = validated_data.pop('email')
|
|
password = validated_data.pop('password')
|
|
first_name = validated_data.pop('first_name', '')
|
|
last_name = validated_data.pop('last_name', '')
|
|
fullname = validated_data.pop('fullname', '')
|
|
user_type = validated_data.pop('user_type', User.UserType.CLIENT)
|
|
|
|
user = User.objects.create_user(
|
|
email=email,
|
|
password=password,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
fullname=fullname,
|
|
username=email,
|
|
user_type=user_type,
|
|
**validated_data
|
|
)
|
|
return user
|
|
|
|
|
|
|
|
class UserMeSerializer(serializers.ModelSerializer):
|
|
bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True)
|
|
roles = serializers.SerializerMethodField()
|
|
permissions = serializers.SerializerMethodField()
|
|
verification_status = serializers.SerializerMethodField()
|
|
is_verified = serializers.SerializerMethodField()
|
|
first_name = serializers.SerializerMethodField()
|
|
last_name = serializers.SerializerMethodField()
|
|
name = serializers.SerializerMethodField()
|
|
phone = serializers.SerializerMethodField()
|
|
preferred_language = serializers.SerializerMethodField()
|
|
title = serializers.SerializerMethodField()
|
|
rank = serializers.SerializerMethodField()
|
|
website = serializers.SerializerMethodField()
|
|
address = serializers.SerializerMethodField()
|
|
position = serializers.SerializerMethodField()
|
|
institution = serializers.SerializerMethodField()
|
|
institution_id = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
'id',
|
|
'email',
|
|
'username',
|
|
'fullname',
|
|
'first_name',
|
|
'last_name',
|
|
'name',
|
|
'phone_number',
|
|
'phone',
|
|
'user_type',
|
|
'avatar',
|
|
'bio',
|
|
'languages',
|
|
'preferred_language',
|
|
'skills',
|
|
'title',
|
|
'rank',
|
|
'position',
|
|
'institution',
|
|
'institution_id',
|
|
'website',
|
|
'address',
|
|
'country',
|
|
'city',
|
|
'gender',
|
|
'birthdate',
|
|
'date_joined',
|
|
'is_active',
|
|
'is_staff',
|
|
'roles',
|
|
'permissions',
|
|
'verification_status',
|
|
'is_verified',
|
|
]
|
|
read_only_fields = [
|
|
'id',
|
|
'email',
|
|
'username',
|
|
'first_name',
|
|
'last_name',
|
|
'name',
|
|
'phone',
|
|
'preferred_language',
|
|
'title',
|
|
'rank',
|
|
'position',
|
|
'institution',
|
|
'institution_id',
|
|
'website',
|
|
'address',
|
|
'user_type',
|
|
'date_joined',
|
|
'is_active',
|
|
'is_staff',
|
|
'roles',
|
|
'permissions',
|
|
'verification_status',
|
|
'is_verified',
|
|
]
|
|
|
|
def get_first_name(self, obj) -> str:
|
|
if obj.first_name:
|
|
return obj.first_name
|
|
if obj.fullname:
|
|
return obj.fullname.strip().split()[0]
|
|
return ""
|
|
|
|
def get_last_name(self, obj) -> str:
|
|
if obj.last_name:
|
|
return obj.last_name
|
|
if obj.fullname:
|
|
parts = obj.fullname.strip().split()
|
|
if len(parts) > 1:
|
|
return " ".join(parts[1:])
|
|
return ""
|
|
|
|
def get_name(self, obj) -> str:
|
|
return obj.fullname or f"{obj.first_name or ''} {obj.last_name or ''}".strip()
|
|
|
|
def get_phone(self, obj) -> str:
|
|
if obj.phone_number:
|
|
raw = (getattr(obj.phone_number, 'as_e164', None) or str(obj.phone_number)).replace(' ', '')
|
|
if raw.startswith('+98') and len(raw) == 13:
|
|
return '0' + raw[3:]
|
|
return str(obj.phone_number)
|
|
return ""
|
|
|
|
def get_preferred_language(self, obj) -> str:
|
|
if obj.languages and isinstance(obj.languages, list) and len(obj.languages) > 0:
|
|
return obj.languages[0]
|
|
return obj.language or "fa"
|
|
|
|
def get_title(self, obj) -> str:
|
|
if obj.title:
|
|
return obj.title
|
|
membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
|
|
if membership and membership.title:
|
|
return membership.title
|
|
return obj.skill or ""
|
|
|
|
def get_rank(self, obj) -> str:
|
|
return obj.rank or ""
|
|
|
|
def get_website(self, obj) -> str:
|
|
return obj.website or ""
|
|
|
|
def get_address(self, obj) -> str:
|
|
return obj.address or ""
|
|
|
|
def get_institution(self, obj) -> str:
|
|
if obj.institution_name:
|
|
return obj.institution_name
|
|
membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
|
|
if membership and membership.institution:
|
|
return membership.institution.name
|
|
return ""
|
|
|
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
|
def get_institution_id(self, obj):
|
|
membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
|
|
if membership and membership.institution:
|
|
return membership.institution.id
|
|
return None
|
|
|
|
def get_position(self, obj) -> str:
|
|
membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
|
|
if membership and membership.role:
|
|
return membership.role
|
|
return ""
|
|
|
|
def get_roles(self, obj) -> list:
|
|
roles = [obj.user_type] if obj.user_type else []
|
|
for group in obj.groups.all():
|
|
roles.append(group.name)
|
|
return list(set(roles))
|
|
|
|
def get_permissions(self, obj) -> list:
|
|
perms = []
|
|
if obj.is_super_admin:
|
|
perms.extend(['super_admin', 'manage_all', 'manage_institutions', 'manage_users', 'manage_content', 'approve_verifications'])
|
|
elif obj.is_regional_admin:
|
|
perms.extend(['regional_admin', 'manage_regional_institutions', 'manage_content', 'approve_verifications'])
|
|
elif obj.is_institution_admin:
|
|
perms.extend(['institution_admin', 'manage_institution_profile', 'manage_team', 'manage_events', 'manage_projects'])
|
|
elif obj.is_editor:
|
|
perms.extend(['editor', 'create_posts', 'manage_events', 'edit_projects'])
|
|
else:
|
|
perms.extend(['client_access', 'view_courses', 'submit_tickets', 'attend_events'])
|
|
return perms
|
|
|
|
def get_verification_status(self, obj) -> str:
|
|
return obj.verification_status
|
|
|
|
def get_is_verified(self, obj) -> bool:
|
|
return obj.verification_status == VerificationDocument.Status.VERIFIED
|
|
|
|
|
|
class UserMeUpdateSerializer(serializers.ModelSerializer):
|
|
bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True)
|
|
first_name = serializers.CharField(required=False, allow_blank=True)
|
|
last_name = serializers.CharField(required=False, allow_blank=True)
|
|
name = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
|
phone = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
|
preferred_language = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
|
expertise = serializers.ListField(child=serializers.CharField(), required=False, write_only=True)
|
|
title = serializers.CharField(required=False, allow_blank=True)
|
|
rank = serializers.CharField(required=False, allow_blank=True)
|
|
institution = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
|
institution_name = serializers.CharField(required=False, allow_blank=True)
|
|
website = serializers.CharField(required=False, allow_blank=True)
|
|
address = serializers.CharField(required=False, allow_blank=True)
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
'fullname',
|
|
'first_name',
|
|
'last_name',
|
|
'name',
|
|
'phone_number',
|
|
'phone',
|
|
'avatar',
|
|
'bio',
|
|
'languages',
|
|
'preferred_language',
|
|
'skills',
|
|
'expertise',
|
|
'title',
|
|
'rank',
|
|
'institution',
|
|
'institution_name',
|
|
'website',
|
|
'address',
|
|
'country',
|
|
'city',
|
|
'gender',
|
|
'birthdate',
|
|
]
|
|
|
|
def to_internal_value(self, data):
|
|
if hasattr(data, 'copy'):
|
|
mutable_data = data.copy()
|
|
else:
|
|
mutable_data = dict(data)
|
|
|
|
# Synchronize first_name, last_name, fullname, and name
|
|
fn = mutable_data.get('first_name')
|
|
ln = mutable_data.get('last_name')
|
|
fl = mutable_data.get('fullname') or mutable_data.get('name')
|
|
|
|
if fn is not None or ln is not None:
|
|
if not fl:
|
|
mutable_data['fullname'] = f"{fn or ''} {ln or ''}".strip()
|
|
elif fl:
|
|
parts = fl.strip().split(maxsplit=1)
|
|
mutable_data['first_name'] = parts[0] if parts else ""
|
|
mutable_data['last_name'] = parts[1] if len(parts) > 1 else ""
|
|
mutable_data['fullname'] = fl.strip()
|
|
|
|
# Harmonize institution -> institution_name
|
|
inst = mutable_data.get('institution') or mutable_data.get('institution_name')
|
|
if inst:
|
|
mutable_data['institution_name'] = inst
|
|
|
|
# Harmonize title
|
|
if 'title' in mutable_data and mutable_data['title']:
|
|
mutable_data['title'] = mutable_data['title']
|
|
|
|
# Harmonize phone / phone_number and normalize local numbers (09... -> +989...)
|
|
raw_phone = mutable_data.get('phone') or mutable_data.get('phone_number')
|
|
if raw_phone:
|
|
raw_phone_str = str(raw_phone).strip()
|
|
if raw_phone_str.startswith('09') and len(raw_phone_str) == 11 and raw_phone_str.isdigit():
|
|
mutable_data['phone_number'] = '+98' + raw_phone_str[1:]
|
|
elif raw_phone_str.startswith('0098'):
|
|
mutable_data['phone_number'] = '+98' + raw_phone_str[4:]
|
|
elif raw_phone_str.startswith('989') and len(raw_phone_str) == 12 and raw_phone_str.isdigit():
|
|
mutable_data['phone_number'] = '+' + raw_phone_str
|
|
else:
|
|
mutable_data['phone_number'] = raw_phone_str
|
|
|
|
if 'expertise' in mutable_data and not mutable_data.get('skills'):
|
|
mutable_data['skills'] = mutable_data['expertise']
|
|
|
|
if 'preferred_language' in mutable_data and not mutable_data.get('languages'):
|
|
mutable_data['languages'] = [mutable_data['preferred_language']]
|
|
|
|
# Handle avatar: support File, base64 Data URI, null (remove), and ignore existing URL strings
|
|
if 'avatar' in mutable_data:
|
|
avatar_val = mutable_data['avatar']
|
|
if isinstance(avatar_val, str):
|
|
if avatar_val.startswith('data:image/'):
|
|
try:
|
|
import base64, uuid
|
|
from django.core.files.base import ContentFile
|
|
format_part, imgstr = avatar_val.split(';base64,', 1)
|
|
ext = format_part.split('/')[-1]
|
|
if ext == 'jpeg':
|
|
ext = 'jpg'
|
|
name_id = uuid.uuid4().hex[:8]
|
|
mutable_data['avatar'] = ContentFile(base64.b64decode(imgstr), name=f"avatar_{name_id}.{ext}")
|
|
except Exception:
|
|
mutable_data.pop('avatar', None)
|
|
elif not avatar_val.strip() or avatar_val.startswith('http') or avatar_val.startswith('/'):
|
|
mutable_data.pop('avatar', None)
|
|
elif avatar_val is None:
|
|
mutable_data['avatar'] = None
|
|
|
|
return super().to_internal_value(mutable_data)
|
|
|
|
class RegisterResponseSerializer(serializers.Serializer):
|
|
user = UserMeSerializer()
|
|
access = serializers.CharField()
|
|
refresh = serializers.CharField()
|
|
message = serializers.CharField()
|
|
|
|
|
|
class VerificationDocumentSerializer(serializers.ModelSerializer):
|
|
document_type_display = serializers.CharField(source='get_document_type_display', read_only=True)
|
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
|
|
|
class Meta:
|
|
model = VerificationDocument
|
|
fields = [
|
|
'id',
|
|
'user',
|
|
'document_type',
|
|
'document_type_display',
|
|
'title',
|
|
'document_file',
|
|
'status',
|
|
'status_display',
|
|
'admin_notes',
|
|
'uploaded_at',
|
|
'reviewed_at',
|
|
]
|
|
read_only_fields = [
|
|
'id',
|
|
'user',
|
|
'document_type_display',
|
|
'status',
|
|
'status_display',
|
|
'admin_notes',
|
|
'uploaded_at',
|
|
'reviewed_at',
|
|
]
|
|
|
|
|
|
class VerificationDocumentUploadSerializer(serializers.Serializer):
|
|
document_type = serializers.ChoiceField(
|
|
choices=VerificationDocument.DocumentType.choices,
|
|
default=VerificationDocument.DocumentType.NATIONAL_ID
|
|
)
|
|
title = serializers.CharField(required=False, allow_blank=True, max_length=255)
|
|
document_file = serializers.FileField(required=True)
|
|
|
|
|
|
class PasswordRecoverRequestSerializer(serializers.Serializer):
|
|
email = serializers.EmailField(required=True)
|
|
|
|
|
|
class PasswordResetRequestSerializer(serializers.Serializer):
|
|
email = serializers.EmailField(required=True)
|
|
token = serializers.CharField(required=True)
|
|
new_password = serializers.CharField(required=True, min_length=6)
|
|
|
|
def validate_new_password(self, value):
|
|
validate_password(value)
|
|
return value
|
|
|
|
|
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|
"""
|
|
JWT token obtain serializer that accepts either 'username' or 'email' as the credential.
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
if 'email' in self.fields:
|
|
self.fields['email'].required = False
|
|
if 'username' not in self.fields:
|
|
self.fields['username'] = serializers.CharField(required=False, write_only=True)
|
|
|
|
def to_internal_value(self, data):
|
|
if isinstance(data, dict):
|
|
mutable_data = data.copy()
|
|
identifier = mutable_data.get('email') or mutable_data.get('username')
|
|
if identifier:
|
|
mutable_data['email'] = identifier
|
|
mutable_data['username'] = identifier
|
|
return super().to_internal_value(mutable_data)
|
|
return super().to_internal_value(data)
|
|
|
|
def validate(self, attrs):
|
|
identifier = attrs.get('email') or attrs.get('username')
|
|
if not identifier:
|
|
raise serializers.ValidationError({"email": _("This field is required.")})
|
|
attrs['email'] = identifier
|
|
attrs['username'] = identifier
|
|
return super().validate(attrs)
|
|
|