UPDATE
This commit is contained in:
+2
-7
@@ -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,12 +16,10 @@ 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
|
||||||
return None
|
return None
|
||||||
|
|||||||
+18
-73
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ExternalAPIAdapter:
|
|||||||
request_method = method.upper()
|
request_method = method.upper()
|
||||||
self._validate_method(request_method)
|
self._validate_method(request_method)
|
||||||
service = self.service_registry.get(service_name)
|
service = self.service_registry.get(service_name)
|
||||||
|
|
||||||
if getattr(settings, "USE_EXTERNAL_API_MOCK", False):
|
if getattr(settings, "USE_EXTERNAL_API_MOCK", False):
|
||||||
mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method)
|
mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method)
|
||||||
return AdapterResponse(
|
return AdapterResponse(
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user