This commit is contained in:
2026-03-25 16:19:28 +03:30
parent 2846db1647
commit f305e00cfe
7 changed files with 63 additions and 121 deletions
+1 -6
View File
@@ -6,10 +6,7 @@ User = get_user_model()
class MultiFieldBackend(ModelBackend): class MultiFieldBackend(ModelBackend):
""" """Authenticate with username, email, or phone_number."""
Authenticate against username, email, or phone_number.
Used for password-based login where the user can enter any of the three.
"""
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
if username is None or password is None: if username is None or password is None:
@@ -19,11 +16,9 @@ class MultiFieldBackend(ModelBackend):
user = User.objects.get( user = User.objects.get(
Q(username=username) | Q(email=username) | Q(phone_number=username) Q(username=username) | Q(email=username) | Q(phone_number=username)
) )
print(user)
except (User.DoesNotExist, User.MultipleObjectsReturned): except (User.DoesNotExist, User.MultipleObjectsReturned):
User().set_password(password) User().set_password(password)
return None return None
print(user.check_password(password) , self.user_can_authenticate(user))
if user.check_password(password) and self.user_can_authenticate(user): if user.check_password(password) and self.user_can_authenticate(user):
return user return user
+18 -73
View File
@@ -1,17 +1,16 @@
import secrets import secrets
from django.contrib.auth import authenticate
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate
from django.core.cache import cache from django.core.cache import cache
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.db import IntegrityError from django.db import IntegrityError
from rest_framework import serializers from rest_framework import serializers, status
from rest_framework import status
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import AccessToken
from account.models import User from account.models import User
from config.swagger import code_response from config.swagger import code_response
@@ -30,7 +29,6 @@ OTP_SIGNER = TimestampSigner(salt="auth.otp")
def _auth_user_to_data(user): def _auth_user_to_data(user):
"""Build AuthUser-shaped dict from Django User."""
if user is None or not getattr(user, "pk", None): if user is None or not getattr(user, "pk", None):
return None return None
return { return {
@@ -43,6 +41,10 @@ def _auth_user_to_data(user):
} }
def _issue_token(user):
return str(AccessToken.for_user(user))
@extend_schema_view( @extend_schema_view(
post=extend_schema( post=extend_schema(
tags=["Authentication"], tags=["Authentication"],
@@ -54,13 +56,6 @@ def _auth_user_to_data(user):
), ),
) )
class RegisterView(APIView): class RegisterView(APIView):
"""
POST /api/auth/register/
Creates a new user with username, email, phone_number, and password.
All fields are required (first_name, last_name optional).
Returns JWT tokens and user data on success.
"""
permission_classes = [AllowAny] permission_classes = [AllowAny]
def post(self, request): def post(self, request):
@@ -87,23 +82,14 @@ class RegisterView(APIView):
detail = "A user with this phone number already exists." detail = "A user with this phone number already exists."
else: else:
detail = "A user with these credentials already exists." detail = "A user with these credentials already exists."
return Response( return Response({"code": 400, "msg": detail}, status=status.HTTP_400_BAD_REQUEST)
{"code": 400, "msg": detail},
status=status.HTTP_400_BAD_REQUEST,
)
refresh = RefreshToken.for_user(user)
user_data = _auth_user_to_data(user)
return Response( return Response(
{ {
"code": 201, "code": 201,
"msg": "success", "msg": "success",
"data": user_data, "data": _auth_user_to_data(user),
"token": { "token": _issue_token(user),
"access": str(refresh.access_token),
"refresh": str(refresh),
},
}, },
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
) )
@@ -120,12 +106,6 @@ class RegisterView(APIView):
), ),
) )
class LoginView(APIView): class LoginView(APIView):
"""
POST /api/auth/login/
Accepts identifier (username, email, or phone_number) + password.
Returns JWT tokens and user data on success.
"""
permission_classes = [AllowAny] permission_classes = [AllowAny]
def post(self, request): def post(self, request):
@@ -134,27 +114,17 @@ class LoginView(APIView):
identifier = serializer.validated_data["identifier"] identifier = serializer.validated_data["identifier"]
password = serializer.validated_data["password"] password = serializer.validated_data["password"]
user = authenticate(request, username=identifier, password=password) user = authenticate(request, username=identifier, password=password)
if user is None: if user is None:
return Response( return Response({"code": 401, "msg": "Invalid credentials."}, status=status.HTTP_401_UNAUTHORIZED)
{"code": 401, "msg": "Invalid credentials."},
status=status.HTTP_401_UNAUTHORIZED,
)
refresh = RefreshToken.for_user(user)
user_data = _auth_user_to_data(user)
return Response( return Response(
{ {
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": user_data, "data": _auth_user_to_data(user),
"token": { "token": _issue_token(user),
"access": str(refresh.access_token),
"refresh": str(refresh),
},
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -178,12 +148,6 @@ class LoginView(APIView):
), ),
) )
class AuthenticationView(APIView): class AuthenticationView(APIView):
"""
Single view for auth flows: request-otp and verify-otp.
Dispatches by path: .../request-otp/ -> request_otp, .../verify-otp/ -> verify_otp.
Response format: RequestOTPResponse / VerifyOTPResponse (code, msg, token, data when applicable).
"""
permission_classes = [AllowAny] permission_classes = [AllowAny]
def post(self, request): def post(self, request):
@@ -197,10 +161,8 @@ class AuthenticationView(APIView):
phone_number = serializer.validated_data["phone_number"].strip() phone_number = serializer.validated_data["phone_number"].strip()
otp_code = f"{secrets.randbelow(1_000_000):06d}" otp_code = f"{secrets.randbelow(1_000_000):06d}"
cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS) cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS)
otp_token = OTP_SIGNER.sign(phone_number) otp_token = OTP_SIGNER.sign(phone_number)
sms_sent = send_otp_sms(phone_number, otp_code) sms_sent = send_otp_sms(phone_number, otp_code)
payload = {"code": 200, "msg": "success", "token": otp_token} payload = {"code": 200, "msg": "success", "token": otp_token}
@@ -208,7 +170,6 @@ class AuthenticationView(APIView):
payload["sms_warning"] = "SMS delivery failed; OTP stored server-side." payload["sms_warning"] = "SMS delivery failed; OTP stored server-side."
if settings.DEBUG: if settings.DEBUG:
payload["debug_otp"] = otp_code payload["debug_otp"] = otp_code
return Response(payload, status=status.HTTP_200_OK) return Response(payload, status=status.HTTP_200_OK)
def _verify_otp(self, request): def _verify_otp(self, request):
@@ -219,24 +180,15 @@ class AuthenticationView(APIView):
otp_code = serializer.validated_data["otp_code"].strip() otp_code = serializer.validated_data["otp_code"].strip()
try: try:
phone_number = OTP_SIGNER.unsign( phone_number = OTP_SIGNER.unsign(token, max_age=OTP_TTL_SECONDS)
token, max_age=OTP_TTL_SECONDS
)
except (BadSignature, SignatureExpired): except (BadSignature, SignatureExpired):
return Response( return Response({"code": 400, "msg": "Token is invalid or expired."}, status=status.HTTP_400_BAD_REQUEST)
{"code": 400, "msg": "Token is invalid or expired."},
status=status.HTTP_400_BAD_REQUEST,
)
cached_otp = cache.get(f"otp_code:{phone_number}") cached_otp = cache.get(f"otp_code:{phone_number}")
if cached_otp is None or cached_otp != otp_code: if cached_otp is None or cached_otp != otp_code:
return Response( return Response({"code": 400, "msg": "OTP code is invalid or expired."}, status=status.HTTP_400_BAD_REQUEST)
{"code": 400, "msg": "OTP code is invalid or expired."},
status=status.HTTP_400_BAD_REQUEST,
)
cache.delete(f"otp_code:{phone_number}") cache.delete(f"otp_code:{phone_number}")
user, created = User.objects.get_or_create( user, created = User.objects.get_or_create(
phone_number=phone_number, phone_number=phone_number,
defaults={ defaults={
@@ -245,19 +197,12 @@ class AuthenticationView(APIView):
}, },
) )
refresh = RefreshToken.for_user(user)
user_data = _auth_user_to_data(user)
return Response( return Response(
{ {
"code": 200, "code": 200,
"msg": "success", "msg": "success",
"data": user_data, "data": _auth_user_to_data(user),
"token": { "token": _issue_token(user),
"access": str(refresh.access_token),
"refresh": str(refresh),
},
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
+9
View File
@@ -1,4 +1,5 @@
import os import os
from datetime import timedelta
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -150,3 +151,11 @@ EXTERNAL_SERVICES = {
"api_key": os.getenv("SENSOR_HUB_SERVICE_API_KEY", ""), "api_key": os.getenv("SENSOR_HUB_SERVICE_API_KEY", ""),
}, },
} }
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=7),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
}
+3 -4
View File
@@ -3,9 +3,8 @@ from rest_framework import serializers
from drf_spectacular.utils import inline_serializer from drf_spectacular.utils import inline_serializer
class TokenPairSerializer(serializers.Serializer): class AuthTokenSerializer(serializers.Serializer):
access = serializers.CharField() token = serializers.CharField()
refresh = serializers.CharField()
def code_response(name, data=None, token=False, extra_fields=None): def code_response(name, data=None, token=False, extra_fields=None):
@@ -16,7 +15,7 @@ def code_response(name, data=None, token=False, extra_fields=None):
if data is not None: if data is not None:
fields["data"] = data fields["data"] = data
if token: if token:
fields["token"] = TokenPairSerializer() fields["token"] = serializers.CharField()
if extra_fields: if extra_fields:
fields.update(extra_fields) fields.update(extra_fields)
return inline_serializer(name=name, fields=fields) return inline_serializer(name=name, fields=fields)
-14
View File
@@ -5,29 +5,15 @@ from .models import Conversation, Message
class ConversationListSerializer(serializers.ModelSerializer): class ConversationListSerializer(serializers.ModelSerializer):
conversation_id = serializers.UUIDField(source="uuid", read_only=True) conversation_id = serializers.UUIDField(source="uuid", read_only=True)
last_message_preview = serializers.SerializerMethodField()
message_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Conversation model = Conversation
fields = [ fields = [
"conversation_id", "conversation_id",
"title", "title",
"farm_context",
"message_count",
"last_message_preview",
"created_at",
"updated_at", "updated_at",
] ]
def get_last_message_preview(self, obj):
last_message = getattr(obj, "last_message", None)
if last_message is None:
last_message = obj.messages.order_by("-created_at", "-id").first()
if last_message is None:
return ""
return (last_message.content or "")[:120]
class MessageSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer):
message_id = serializers.UUIDField(source="uuid", read_only=True) message_id = serializers.UUIDField(source="uuid", read_only=True)
+30 -22
View File
@@ -1,6 +1,5 @@
"""Farm AI Assistant API views.""" """Farm AI Assistant API views."""
from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -36,15 +35,7 @@ class ChatListView(APIView):
responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationListSerializer(many=True))}, responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationListSerializer(many=True))},
) )
def get(self, request): def get(self, request):
last_message_subquery = Message.objects.filter(conversation=OuterRef("pk")).order_by("-created_at", "-id") conversations = Conversation.objects.filter(owner=request.user).order_by("-updated_at", "-created_at")
conversations = (
Conversation.objects.filter(owner=request.user)
.annotate(
message_count=Count("messages"),
last_message=Subquery(last_message_subquery.values("content")[:1]),
)
.order_by("-updated_at", "-created_at")
)
serializer = ConversationListSerializer(conversations, many=True) serializer = ConversationListSerializer(conversations, many=True)
return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK) return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK)
@@ -139,11 +130,29 @@ class ChatView(APIView):
) )
if isinstance(adapter_response.data, dict) and "body" in adapter_response.data: if isinstance(adapter_response.data, dict) and "body" in adapter_response.data:
conversation.save(update_fields=["updated_at"]) assistant_content = adapter_response.data.get("body") or ""
return HttpResponse( assistant_message = Message.objects.create(
adapter_response.data["body"], conversation=conversation,
role=Message.ROLE_ASSISTANT,
content=assistant_content,
raw_response=adapter_response.data,
)
if not conversation.title:
conversation.title = (validated.get("content", "") or assistant_content or "New chat")[:255]
conversation.save(update_fields=["title", "updated_at"])
else:
conversation.save(update_fields=["updated_at"])
return Response(
{
"conversation_id": str(conversation.uuid),
"user_message_id": str(user_message.uuid),
"assistant_message_id": str(assistant_message.uuid),
"content": assistant_content,
"content_type": adapter_response.data.get("content_type", "text/plain; charset=utf-8"),
},
status=adapter_response.status_code, status=adapter_response.status_code,
content_type=adapter_response.data.get("content_type", "text/plain; charset=utf-8"),
) )
assistant_content = "" assistant_content = ""
@@ -165,23 +174,22 @@ class ChatView(APIView):
else: else:
conversation.save(update_fields=["updated_at"]) conversation.save(update_fields=["updated_at"])
conversation_uuid = str(conversation.uuid)
response_data = adapter_response.data response_data = adapter_response.data
if isinstance(response_data, dict): if isinstance(response_data, dict):
response_data.setdefault("conversation_id", conversation_uuid)
data = response_data.get("data") data = response_data.get("data")
if isinstance(data, dict): if isinstance(data, dict):
data.setdefault("conversation_id", str(conversation.uuid)) data.setdefault("conversation_id", conversation_uuid)
data.setdefault("user_message_id", str(user_message.uuid)) data.setdefault("user_message_id", str(user_message.uuid))
data.setdefault("assistant_message_id", str(assistant_message.uuid)) data.setdefault("assistant_message_id", str(assistant_message.uuid))
else: else:
response_data = { response_data.setdefault("user_message_id", str(user_message.uuid))
"conversation_id": str(conversation.uuid), response_data.setdefault("assistant_message_id", str(assistant_message.uuid))
"user_message_id": str(user_message.uuid),
"assistant_message_id": str(assistant_message.uuid),
"response": response_data,
}
else: else:
response_data = { response_data = {
"conversation_id": str(conversation.uuid), "conversation_id": conversation_uuid,
"user_message_id": str(user_message.uuid), "user_message_id": str(user_message.uuid),
"assistant_message_id": str(assistant_message.uuid), "assistant_message_id": str(assistant_message.uuid),
"response": response_data, "response": response_data,