diff --git a/users/migrations/0008_add_unique_name.py b/users/migrations/0008_add_unique_name.py new file mode 100644 index 0000000..4a6e4bd --- /dev/null +++ b/users/migrations/0008_add_unique_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2026-01-13 04:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_customuser_address_customuser_birth_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='name', + field=models.CharField(max_length=255, unique=True), + ), + ] diff --git a/users/models.py b/users/models.py index 10106db..ffd39b4 100644 --- a/users/models.py +++ b/users/models.py @@ -51,7 +51,7 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): ) email = models.EmailField(unique=True) - name = models.CharField(max_length=255) + name = models.CharField(max_length=255, unique=True) grade = models.CharField(max_length=20, choices=GRADE_CHOICES, default='user') desc = models.TextField(blank=True, null=True, verbose_name="설명") diff --git a/users/serializers.py b/users/serializers.py index 874a913..caf64d7 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -11,6 +11,16 @@ class RegisterSerializer(serializers.ModelSerializer): fields = ("email", "name", "password", "grade", "desc", "phone", "address", "gender", "birth_date", "education") + def validate_email(self, value): + if CustomUser.objects.filter(email=value).exists(): + raise ValidationError("이미 사용 중인 이메일입니다.") + return value + + def validate_name(self, value): + if CustomUser.objects.filter(name=value).exists(): + raise ValidationError("이미 사용 중인 이름입니다.") + return value + def create(self, validated_data): password = validated_data.pop("password") user = CustomUser(**validated_data) @@ -19,7 +29,24 @@ class RegisterSerializer(serializers.ModelSerializer): return user +class UserListSerializer(serializers.ModelSerializer): + """관리자용 사용자 목록 시리얼라이저""" + class Meta: + model = CustomUser + fields = [ + 'id', 'email', 'name', 'grade', 'is_active', 'is_staff', + 'created_at', 'phone', 'address', 'gender', 'birth_date', 'education' + ] + read_only_fields = [ + 'id', 'email', 'name', 'grade', 'is_staff', + 'created_at', 'phone', 'address', 'gender', 'birth_date', 'education' + ] + + class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + # email 필드를 identifier로 재정의 (이메일 또는 이름 허용) + email = serializers.CharField() + @classmethod def get_token(cls, user): token = super().get_token(user) @@ -36,18 +63,23 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): return token def validate(self, attrs): - email = attrs.get("email") + identifier = attrs.get("email") # 이메일 또는 이름 password = attrs.get("password") - user = CustomUser.objects.filter(email=email).first() + # 이메일 또는 이름으로 사용자 찾기 + user = CustomUser.objects.filter(email=identifier).first() + if user is None: + user = CustomUser.objects.filter(name=identifier).first() if user is None: - raise ValidationError("이메일 또는 비밀번호가 올바르지 않습니다.") + raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.") if not user.is_active: raise ValidationError("계정이 비활성화되어 있습니다. 관리자에게 문의하세요.") if not user.check_password(password): - raise ValidationError("이메일 또는 비밀번호가 올바르지 않습니다.") + raise ValidationError("계정 또는 비밀번호가 올바르지 않습니다.") + # 부모 클래스의 validate를 위해 attrs에 실제 email 설정 + attrs["email"] = user.email self.user = user # ✅ 수동 설정 필요 data = super().validate(attrs) diff --git a/users/urls.py b/users/urls.py index 61c9d5a..8a36636 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,5 +1,9 @@ from django.urls import path -from .views import RegisterView, MeView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView +from .views import ( + RegisterView, MeView, CustomTokenObtainPairView, + SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView, + UserListView, UserUpdateView +) from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .views_jwks import jwks_view # django-jwks @@ -14,4 +18,7 @@ urlpatterns = [ path("ssh-key/info/", SSHKeyInfoView.as_view(), name="ssh_key_info"), path("ssh-key/view/", SSHKeyRetrieveView.as_view(), name="ssh_key_retrieve"), path(".well-known/jwks.json", jwks_view, name="jwks"), # django-jwks + # 관리자용 사용자 관리 API + path('users/', UserListView.as_view(), name='user_list'), + path('users//', UserUpdateView.as_view(), name='user_update'), ] diff --git a/users/views.py b/users/views.py index b5909d8..227a177 100644 --- a/users/views.py +++ b/users/views.py @@ -4,9 +4,11 @@ from opentelemetry import trace # ✅ OpenTelemetry 트레이서 from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, BasePermission from rest_framework_simplejwt.views import TokenObtainPairView -from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer +from rest_framework import generics +from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer, UserListSerializer +from .models import CustomUser logger = logging.getLogger(__name__) tracer = trace.get_tracer(__name__) # ✅ 트레이서 생성 @@ -233,3 +235,87 @@ class SSHKeyRetrieveView(APIView): attributes={"email": email, "reason": str(e)}, ) # ✅ return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + + +# ============================================ +# 관리자용 사용자 관리 API +# ============================================ + +class IsAdminOrManager(BasePermission): + """admin 또는 manager 등급만 접근 가능""" + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + return request.user.grade in ['admin', 'manager'] + + +class UserListView(generics.ListAPIView): + """사용자 목록 조회 (관리자 전용)""" + queryset = CustomUser.objects.all().order_by('-created_at') + serializer_class = UserListSerializer + permission_classes = [IsAuthenticated, IsAdminOrManager] + + def list(self, request, *args, **kwargs): + with tracer.start_as_current_span("UserListView GET") as span: + email, ip, ua = get_request_info(request) + logger.info(f"[USER LIST] admin={email} | IP={ip} | UA={ua}") + span.add_event("User list retrieved", attributes={"admin": email}) + return super().list(request, *args, **kwargs) + + +class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): + """사용자 상태 수정/삭제 (관리자 전용)""" + queryset = CustomUser.objects.all() + serializer_class = UserListSerializer + permission_classes = [IsAuthenticated, IsAdminOrManager] + + def partial_update(self, request, *args, **kwargs): + with tracer.start_as_current_span("UserUpdateView PATCH") as span: + admin_email, ip, ua = get_request_info(request) + instance = self.get_object() + target_email = instance.email + + # is_active만 수정 가능 + is_active = request.data.get('is_active') + if is_active is not None: + instance.is_active = is_active + instance.save(update_fields=['is_active']) + + action = "activated" if is_active else "deactivated" + logger.info( + f"[USER UPDATE] admin={admin_email} | target={target_email} | action={action} | IP={ip} | UA={ua}" + ) + span.add_event( + f"User {action}", + attributes={"admin": admin_email, "target": target_email} + ) + + serializer = self.get_serializer(instance) + return Response(serializer.data) + + def destroy(self, request, *args, **kwargs): + with tracer.start_as_current_span("UserUpdateView DELETE") as span: + admin_email, ip, ua = get_request_info(request) + instance = self.get_object() + target_email = instance.email + + # 자기 자신은 삭제 불가 + if request.user.id == instance.id: + return Response( + {"error": "자기 자신의 계정은 삭제할 수 없습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + logger.info( + f"[USER DELETE] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" + ) + span.add_event( + "User deleted", + attributes={"admin": admin_email, "target": target_email} + ) + + instance.delete() + return Response( + {"message": f"사용자 {target_email}이(가) 삭제되었습니다."}, + status=status.HTTP_200_OK + ) diff --git a/version b/version index cea538f..4f4d7f3 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.17 \ No newline at end of file +v0.0.18 \ No newline at end of file