├── project ├── __init__.py ├── __pycache__ │ ├── urls.cpython-312.pyc │ ├── __init__.cpython-312.pyc │ └── settings.cpython-312.pyc ├── asgi.py ├── wsgi.py ├── urls.py └── settings.py ├── accounts ├── __pycache__ │ ├── admin.cpython-312.pyc │ ├── apps.cpython-312.pyc │ ├── models.cpython-312.pyc │ ├── tests.cpython-312.pyc │ ├── urls.cpython-312.pyc │ ├── views.cpython-312.pyc │ ├── signals.cpython-312.pyc │ └── serializers.cpython-312.pyc ├── apps.py ├── signals.py ├── urls.py ├── admin.py ├── models.py ├── serializers.py ├── tests.py └── views.py ├── manage.py └── README.md /project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project/__pycache__/urls.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/project/__pycache__/urls.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/admin.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/admin.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/apps.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/apps.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/models.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/models.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/tests.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/tests.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/urls.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/urls.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/views.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/views.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/signals.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/signals.cpython-312.pyc -------------------------------------------------------------------------------- /project/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/project/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /project/__pycache__/settings.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/project/__pycache__/settings.cpython-312.pyc -------------------------------------------------------------------------------- /accounts/__pycache__/serializers.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parvvaresh/User-Management/HEAD/accounts/__pycache__/serializers.cpython-312.pyc -------------------------------------------------------------------------------- /project/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.core.asgi import get_asgi_application 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "labnet.settings") 5 | application = get_asgi_application() 6 | -------------------------------------------------------------------------------- /project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.core.wsgi import get_wsgi_application 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "labnet.settings") 5 | application = get_wsgi_application() 6 | -------------------------------------------------------------------------------- /project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("api/", include("accounts.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "accounts" 7 | 8 | def ready(self): 9 | from . import signals # noqa: F401 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def main(): 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 7 | from django.core.management import execute_from_command_line 8 | 9 | execute_from_command_line(sys.argv) 10 | 11 | 12 | if __name__ == "__main__": 13 | main() 14 | -------------------------------------------------------------------------------- /accounts/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | from .models import User, Profile 4 | 5 | 6 | @receiver(post_save, sender=User) 7 | def ensure_profile(sender, instance, created, **kwargs): 8 | if created: 9 | Profile.objects.get_or_create(user=instance) 10 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework.routers import DefaultRouter 3 | from .views import ( 4 | RegisterView, 5 | PhoneTokenObtainPairView, 6 | OTPStartView, 7 | OTPVerifyView, 8 | ProfileViewSet, 9 | CredentialsView, 10 | ) 11 | 12 | router = DefaultRouter() 13 | router.register(r"profiles", ProfileViewSet, basename="profiles") 14 | 15 | urlpatterns = [ 16 | # Auth flows 17 | path("auth/register/", RegisterView.as_view(), name="register"), 18 | path( 19 | "auth/token/", PhoneTokenObtainPairView.as_view(), name="token_obtain_pair" 20 | ), # password login 21 | path( 22 | "auth/otp/start/", OTPStartView.as_view(), name="otp_start" 23 | ), # dev OTP -> terminal 24 | path( 25 | "auth/otp/verify/", OTPVerifyView.as_view(), name="otp_verify" 26 | ), # returns JWTs 27 | # Profiles & credentials 28 | path("me/credentials/", CredentialsView.as_view(), name="me_credentials"), 29 | path("", include(router.urls)), 30 | ] 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # User Management 2 | 3 | A Django REST API for user authentication, registration, and profile management. 4 | 5 | ## Features 6 | - JWT authentication 7 | - User registration 8 | - Profile auto-creation 9 | - Update credentials 10 | - Profile view and edit 11 | 12 | ## Setup 13 | 1. Clone the repository: 14 | ```sh 15 | git clone 16 | cd User-Management 17 | ``` 18 | 2. Create and activate a virtual environment: 19 | ```sh 20 | python3 -m venv myenv 21 | source myenv/bin/activate 22 | ``` 23 | 3. Install dependencies: 24 | ```sh 25 | pip install -r requirements.txt 26 | ``` 27 | 4. Apply migrations: 28 | ```sh 29 | python manage.py migrate 30 | ``` 31 | 5. Run the development server: 32 | ```sh 33 | python manage.py runserver 34 | ``` 35 | 36 | ## Running Tests 37 | ```sh 38 | python manage.py test accounts 39 | ``` 40 | 41 | ## API Endpoints 42 | - `/api/register/` - Register a new user 43 | - `/api/token/` - Obtain JWT token 44 | - `/api/profiles/me/` - Get or update current user's profile 45 | - `/api/profiles/` - List all profiles 46 | - `/api/me/credentials/` - Update credentials 47 | 48 | -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 3 | from .models import User, Profile 4 | 5 | 6 | @admin.register(User) 7 | class UserAdmin(BaseUserAdmin): 8 | list_display = ("id", "phone_number", "username", "is_staff", "is_active") 9 | ordering = ("phone_number",) 10 | 11 | fieldsets = ( 12 | (None, {"fields": ("phone_number", "username", "password")}), 13 | ( 14 | "Permissions", 15 | { 16 | "fields": ( 17 | "is_active", 18 | "is_staff", 19 | "is_superuser", 20 | "groups", 21 | "user_permissions", 22 | ) 23 | }, 24 | ), 25 | ("Important dates", {"fields": ("last_login",)}), 26 | ) 27 | add_fieldsets = ((None, {"fields": ("phone_number", "password1", "password2")}),) 28 | search_fields = ("phone_number", "username") 29 | readonly_fields = ("last_login",) 30 | 31 | 32 | @admin.register(Profile) 33 | class ProfileAdmin(admin.ModelAdmin): 34 | list_display = ("id", "user", "full_name") 35 | search_fields = ("user__phone_number", "full_name") 36 | -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import ( 3 | AbstractBaseUser, 4 | PermissionsMixin, 5 | BaseUserManager, 6 | ) 7 | from django.core.validators import RegexValidator 8 | from django.utils import timezone 9 | 10 | 11 | _phone_validator = RegexValidator( 12 | regex=r"^\+?\d{8,15}$", 13 | message="Enter a valid phone number (8-15 digits, optional +).", 14 | ) 15 | 16 | 17 | class UserManager(BaseUserManager): 18 | def create_user(self, phone_number, password=None, **extra_fields): 19 | if not phone_number: 20 | raise ValueError("The phone number must be set") 21 | user = self.model(phone_number=phone_number, **extra_fields) 22 | if password: 23 | user.set_password(password) 24 | else: 25 | user.set_unusable_password() 26 | user.save(using=self._db) 27 | return user 28 | 29 | def create_superuser(self, phone_number, password=None, **extra_fields): 30 | extra_fields.setdefault("is_staff", True) 31 | extra_fields.setdefault("is_superuser", True) 32 | return self.create_user(phone_number, password, **extra_fields) 33 | 34 | 35 | class User(AbstractBaseUser, PermissionsMixin): 36 | phone_number = models.CharField( 37 | max_length=11, unique=True, validators=[_phone_validator] 38 | ) 39 | username = models.CharField(max_length=150, unique=True, null=True, blank=True) 40 | is_active = models.BooleanField(default=True) 41 | is_staff = models.BooleanField(default=False) 42 | date_joined = models.DateTimeField(default=timezone.now) 43 | 44 | USERNAME_FIELD = "phone_number" 45 | REQUIRED_FIELDS = [] 46 | 47 | objects = UserManager() 48 | 49 | def __str__(self): 50 | return self.phone_number 51 | 52 | 53 | class Profile(models.Model): 54 | user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") 55 | full_name = models.CharField(max_length=255, blank=True) 56 | bio = models.TextField(blank=True) 57 | avatar_url = models.URLField(blank=True) 58 | 59 | def __str__(self): 60 | return f"Profile of {self.user.phone_number}" 61 | -------------------------------------------------------------------------------- /accounts/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from django.contrib.auth import get_user_model 3 | from .models import Profile 4 | 5 | User = get_user_model() 6 | 7 | 8 | class RegisterSerializer(serializers.ModelSerializer): 9 | class Meta: 10 | model = User 11 | fields = ("phone_number", "password", "username") 12 | extra_kwargs = {"password": {"write_only": True, "required": False}} 13 | 14 | def create(self, validated): 15 | password = validated.pop("password", None) 16 | user = User.objects.create(**validated) 17 | if password: 18 | user.set_password(password) 19 | user.save() 20 | return user 21 | 22 | 23 | class ProfileSerializer(serializers.ModelSerializer): 24 | phone_number = serializers.CharField(source="user.phone_number", read_only=True) 25 | username = serializers.CharField(source="user.username", read_only=True) 26 | 27 | class Meta: 28 | model = Profile 29 | fields = ("phone_number", "username", "full_name", "bio", "avatar_url") 30 | 31 | 32 | class ProfileUpdateSerializer(serializers.ModelSerializer): 33 | class Meta: 34 | model = Profile 35 | fields = ("full_name", "bio", "avatar_url") 36 | 37 | 38 | class CredentialsUpdateSerializer(serializers.Serializer): 39 | username = serializers.CharField(required=False, allow_blank=True) 40 | password = serializers.CharField(required=False, write_only=True, min_length=8) 41 | 42 | def validate_username(self, value): 43 | user_model = self.context["request"].user.__class__ 44 | if ( 45 | value 46 | and user_model.objects.filter(username=value) 47 | .exclude(pk=self.context["request"].user.pk) 48 | .exists() 49 | ): 50 | raise serializers.ValidationError("Username already taken.") 51 | return value 52 | 53 | def update(self, instance, validated): 54 | if "username" in validated: 55 | instance.username = validated["username"] or None 56 | if "password" in validated: 57 | instance.set_password(validated["password"]) 58 | instance.save() 59 | return instance 60 | 61 | 62 | class OTPStartSerializer(serializers.Serializer): 63 | phone_number = serializers.CharField() 64 | 65 | 66 | class OTPVerifySerializer(serializers.Serializer): 67 | phone_number = serializers.CharField() 68 | otp = serializers.CharField() 69 | -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.contrib.auth import get_user_model 3 | from rest_framework.test import APITestCase 4 | from rest_framework import status 5 | 6 | User = get_user_model() 7 | 8 | 9 | class AuthFlowTests(APITestCase): 10 | def setUp(self): 11 | self.user = User.objects.create_user( 12 | phone_number="+491601234567", password="S3cur3pass!" 13 | ) 14 | 15 | def test_password_login(self): 16 | url = reverse("token_obtain_pair") 17 | res = self.client.post( 18 | url, 19 | {"phone_number": "+491601234567", "password": "S3cur3pass!"}, 20 | format="json", 21 | ) 22 | self.assertEqual(res.status_code, status.HTTP_200_OK) 23 | self.assertIn("access", res.data) 24 | self.assertIn("refresh", res.data) 25 | 26 | def test_register_and_profile_auto_create(self): 27 | url = reverse("register") 28 | res = self.client.post( 29 | url, { 30 | "phone_number": "09121111111", 31 | "username": "newbie", 32 | "password": "S3cur3pass!" 33 | }, format="json" 34 | ) 35 | print(res.data) 36 | self.assertEqual(res.status_code, 201) 37 | u = User.objects.get(phone_number="09121111111") 38 | self.assertTrue(hasattr(u, "profile")) 39 | 40 | 41 | class ProfileTests(APITestCase): 42 | def setUp(self): 43 | self.user = User.objects.create_user( 44 | phone_number="+491601234567", password="S3cur3pass!", username="alice" 45 | ) 46 | token = self.client.post( 47 | reverse("token_obtain_pair"), 48 | {"phone_number": "+491601234567", "password": "S3cur3pass!"}, 49 | ).data["access"] 50 | self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") 51 | 52 | def test_me_get_and_patch(self): 53 | res = self.client.get("/api/profiles/me/") 54 | self.assertEqual(res.status_code, 200) 55 | res = self.client.patch( 56 | "/api/profiles/me/", {"full_name": "Alice Doe"}, format="json" 57 | ) 58 | self.assertEqual(res.status_code, 200) 59 | self.assertEqual(res.data["full_name"], "Alice Doe") 60 | 61 | def test_list_and_retrieve_profiles(self): 62 | other = User.objects.create_user(phone_number="+491602222222") 63 | res = self.client.get("/api/profiles/") 64 | self.assertEqual(res.status_code, 200) 65 | self.assertGreaterEqual(len(res.data), 2) 66 | res = self.client.get(f"/api/profiles/{other.profile.id}/") 67 | self.assertEqual(res.status_code, 200) 68 | 69 | def test_update_credentials(self): 70 | res = self.client.patch( 71 | "/api/me/credentials/", 72 | {"username": "newalice", "password": "NewStr0ngPass!"}, 73 | format="json", 74 | ) 75 | self.assertEqual(res.status_code, 200) 76 | # can log in with new password 77 | res = self.client.post( 78 | reverse("token_obtain_pair"), 79 | {"phone_number": "+491601234567", "password": "NewStr0ngPass!"}, 80 | format="json", 81 | ) 82 | self.assertEqual(res.status_code, 200) 83 | -------------------------------------------------------------------------------- /project/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os 3 | from datetime import timedelta 4 | from dotenv import load_dotenv 5 | 6 | BASE_DIR = Path(__file__).resolve().parent.parent 7 | load_dotenv(BASE_DIR / ".env") 8 | 9 | SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key") 10 | DEBUG = os.getenv("DJANGO_DEBUG", "True").lower() == "true" 11 | ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") 12 | 13 | INSTALLED_APPS = [ 14 | "django.contrib.admin", 15 | "django.contrib.auth", 16 | "django.contrib.contenttypes", 17 | "django.contrib.sessions", 18 | "django.contrib.messages", 19 | "django.contrib.staticfiles", 20 | # 3rd party 21 | "rest_framework", 22 | # Local 23 | "accounts", 24 | ] 25 | 26 | MIDDLEWARE = [ 27 | "django.middleware.security.SecurityMiddleware", 28 | "django.contrib.sessions.middleware.SessionMiddleware", 29 | "django.middleware.common.CommonMiddleware", 30 | "django.middleware.csrf.CsrfViewMiddleware", 31 | "django.contrib.auth.middleware.AuthenticationMiddleware", 32 | "django.contrib.messages.middleware.MessageMiddleware", 33 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 34 | ] 35 | 36 | ROOT_URLCONF = "project.urls" 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "DIRS": [], 42 | "APP_DIRS": True, 43 | "OPTIONS": { 44 | "context_processors": [ 45 | "django.template.context_processors.debug", 46 | "django.template.context_processors.request", 47 | "django.contrib.auth.context_processors.auth", 48 | "django.contrib.messages.context_processors.messages", 49 | ], 50 | }, 51 | }, 52 | ] 53 | 54 | WSGI_APPLICATION = "labnet.wsgi.application" 55 | 56 | # Database 57 | DB_ENGINE = os.getenv("DJANGO_DB_ENGINE", "django.db.backends.sqlite3") 58 | if DB_ENGINE.endswith("sqlite3"): 59 | DATABASES = { 60 | "default": { 61 | "ENGINE": DB_ENGINE, 62 | "NAME": BASE_DIR / os.getenv("DJANGO_DB_NAME", "db.sqlite3"), 63 | } 64 | } 65 | else: 66 | DATABASES = { 67 | "default": { 68 | "ENGINE": DB_ENGINE, 69 | "NAME": os.getenv("DJANGO_DB_NAME", "labnet"), 70 | "USER": os.getenv("DJANGO_DB_USER", "labnet"), 71 | "PASSWORD": os.getenv("DJANGO_DB_PASSWORD", ""), 72 | "HOST": os.getenv("DJANGO_DB_HOST", "localhost"), 73 | "PORT": os.getenv("DJANGO_DB_PORT", "5432"), 74 | } 75 | } 76 | 77 | # Password validation 78 | AUTH_PASSWORD_VALIDATORS = [ 79 | { 80 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 81 | }, 82 | { 83 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 84 | "OPTIONS": {"min_length": 8}, 85 | }, 86 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 87 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 88 | ] 89 | 90 | LANGUAGE_CODE = "en-us" 91 | TIME_ZONE = "Europe/Berlin" 92 | USE_I18N = True 93 | USE_TZ = True 94 | 95 | STATIC_URL = "static/" 96 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 97 | 98 | # --- Auth / DRF / JWT --- 99 | AUTH_USER_MODEL = "accounts.User" 100 | 101 | REST_FRAMEWORK = { 102 | "DEFAULT_AUTHENTICATION_CLASSES": ( 103 | "rest_framework_simplejwt.authentication.JWTAuthentication", 104 | ), 105 | "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), 106 | } 107 | 108 | SIMPLE_JWT = { 109 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), 110 | "REFRESH_TOKEN_LIFETIME": timedelta(days=7), 111 | } 112 | 113 | # --- Caching for OTP codes (in-memory) --- 114 | CACHES = { 115 | "default": { 116 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 117 | "LOCATION": "labnet-cache", 118 | } 119 | } 120 | 121 | # --- Logging: print OTP codes to console --- 122 | LOGGING = { 123 | "version": 1, 124 | "disable_existing_loggers": False, 125 | "handlers": { 126 | "console": {"class": "logging.StreamHandler"}, 127 | }, 128 | "loggers": { 129 | "accounts": {"handlers": ["console"], "level": "WARNING"}, 130 | }, 131 | } 132 | -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | import random 2 | import logging 3 | from django.contrib.auth import get_user_model 4 | from django.core.cache import cache 5 | from rest_framework import generics, permissions, status, viewsets, mixins 6 | from rest_framework.decorators import action 7 | from rest_framework.response import Response 8 | from rest_framework_simplejwt.views import TokenObtainPairView 9 | from rest_framework_simplejwt.serializers import TokenObtainPairSerializer 10 | from rest_framework_simplejwt.tokens import RefreshToken 11 | from .serializers import ( 12 | RegisterSerializer, 13 | ProfileSerializer, 14 | ProfileUpdateSerializer, 15 | CredentialsUpdateSerializer, 16 | OTPStartSerializer, 17 | OTPVerifySerializer, 18 | ) 19 | from .models import Profile 20 | 21 | logger = logging.getLogger(__name__) 22 | User = get_user_model() 23 | 24 | 25 | # ---- JWT with phone_number (password-based) ---- 26 | class PhoneTokenObtainPairSerializer(TokenObtainPairSerializer): 27 | @classmethod 28 | def get_token(cls, user): 29 | token = super().get_token(user) 30 | token["phone_number"] = user.phone_number 31 | return token 32 | 33 | def validate(self, attrs): 34 | # Map "phone_number" to expected "username" field 35 | if "phone_number" in self.initial_data and "username" not in attrs: 36 | attrs["username"] = self.initial_data.get("phone_number") 37 | return super().validate(attrs) 38 | 39 | 40 | class PhoneTokenObtainPairView(TokenObtainPairView): 41 | permission_classes = [permissions.AllowAny] 42 | serializer_class = PhoneTokenObtainPairSerializer 43 | 44 | 45 | # ---- OTP (prints to terminal) ---- 46 | 47 | 48 | def _otp_key(phone: str) -> str: 49 | return f"otp:{phone}" 50 | 51 | 52 | class OTPStartView(generics.GenericAPIView): 53 | permission_classes = [permissions.AllowAny] 54 | serializer_class = OTPStartSerializer 55 | 56 | def post(self, request): 57 | s = self.get_serializer(data=request.data) 58 | s.is_valid(raise_exception=True) 59 | phone = s.validated_data["phone_number"] 60 | otp = f"{random.randint(0, 999999):06d}" 61 | cache.set(_otp_key(phone), otp, timeout=300) # 5 minutes 62 | logger.warning("DEV OTP for %s is %s", phone, otp) # shows in terminal 63 | return Response( 64 | {"detail": "OTP generated (check server terminal)."}, status=200 65 | ) 66 | 67 | 68 | class OTPVerifyView(generics.GenericAPIView): 69 | permission_classes = [permissions.AllowAny] 70 | serializer_class = OTPVerifySerializer 71 | 72 | def post(self, request): 73 | s = self.get_serializer(data=request.data) 74 | s.is_valid(raise_exception=True) 75 | phone = s.validated_data["phone_number"] 76 | otp = s.validated_data["otp"] 77 | expected = cache.get(_otp_key(phone)) 78 | if not expected or expected != otp: 79 | return Response({"detail": "Invalid OTP."}, status=400) 80 | cache.delete(_otp_key(phone)) 81 | user, _ = User.objects.get_or_create(phone_number=phone) 82 | refresh = RefreshToken.for_user(user) 83 | return Response( 84 | {"access": str(refresh.access_token), "refresh": str(refresh)}, status=200 85 | ) 86 | 87 | 88 | # ---- Registration + Profiles ---- 89 | class RegisterView(generics.CreateAPIView): 90 | permission_classes = [permissions.AllowAny] 91 | serializer_class = RegisterSerializer 92 | 93 | 94 | class ProfileViewSet( 95 | mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet 96 | ): 97 | queryset = Profile.objects.select_related("user").all() 98 | permission_classes = [permissions.IsAuthenticated] 99 | serializer_class = ProfileSerializer 100 | 101 | @action(detail=False, methods=["get", "patch"]) # /api/profiles/me/ 102 | def me(self, request): 103 | profile = request.user.profile 104 | if request.method == "GET": 105 | return Response(ProfileSerializer(profile).data) 106 | s = ProfileUpdateSerializer(instance=profile, data=request.data, partial=True) 107 | s.is_valid(raise_exception=True) 108 | s.save() 109 | return Response(ProfileSerializer(profile).data) 110 | 111 | 112 | class CredentialsView(generics.GenericAPIView): 113 | serializer_class = CredentialsUpdateSerializer 114 | 115 | def patch(self, request): 116 | s = self.get_serializer( 117 | data=request.data, partial=True, context={"request": request} 118 | ) 119 | s.is_valid(raise_exception=True) 120 | s.update(request.user, s.validated_data) 121 | return Response({"detail": "Credentials updated."}) 122 | --------------------------------------------------------------------------------