diff --git a/account/backends.py b/account/backends.py new file mode 100644 index 0000000..d8510f8 --- /dev/null +++ b/account/backends.py @@ -0,0 +1,28 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q + +User = get_user_model() + + +class MultiFieldBackend(ModelBackend): + """ + 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): + if username is None or password is None: + return None + + try: + user = User.objects.get( + Q(username=username) | Q(email=username) | Q(phone_number=username) + ) + except (User.DoesNotExist, User.MultipleObjectsReturned): + User().set_password(password) + return None + + if user.check_password(password) and self.user_can_authenticate(user): + return user + return None diff --git a/account/migrations/0002_alter_user_managers_alter_user_email.py b/account/migrations/0002_alter_user_managers_alter_user_email.py new file mode 100644 index 0000000..91f4ca5 --- /dev/null +++ b/account/migrations/0002_alter_user_managers_alter_user_email.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.15 on 2026-03-23 18:48 + +import account.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', account.models.CustomUserManager()), + ], + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email address'), + ), + ] diff --git a/account/models.py b/account/models.py index 3eda4eb..bb04f7b 100644 --- a/account/models.py +++ b/account/models.py @@ -1,19 +1,37 @@ from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import UserManager as BaseUserManager +from django.db.models import Q from django.db import models +class CustomUserManager(BaseUserManager): + """Manager that allows lookup by username, email, or phone_number.""" + + def get_by_natural_key(self, username): + return self.get( + Q(username=username) | Q(email=username) | Q(phone_number=username) + ) + + class User(AbstractUser): phone_number = models.CharField( max_length=32, unique=True, db_index=True, ) + email = models.EmailField( + "email address", + unique=True, + db_index=True, + ) - USERNAME_FIELD = "phone_number" - REQUIRED_FIELDS = ["username"] + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email", "phone_number"] + + objects = CustomUserManager() class Meta: db_table = "users" def __str__(self): - return self.phone_number + return self.username diff --git a/auth/serializers.py b/auth/serializers.py index 8677ed8..30fe98e 100644 --- a/auth/serializers.py +++ b/auth/serializers.py @@ -1,6 +1,27 @@ from rest_framework import serializers +# --- Register --- +class RegisterSerializer(serializers.Serializer): + """Request body for POST /api/auth/register/.""" + + username = serializers.CharField(max_length=150) + email = serializers.EmailField() + phone_number = serializers.CharField(max_length=32) + password = serializers.CharField(min_length=8, write_only=True) + first_name = serializers.CharField(max_length=150, required=False, default="") + last_name = serializers.CharField(max_length=150, required=False, default="") + + +# --- Login --- +class LoginSerializer(serializers.Serializer): + """Request body for POST /api/auth/login/. + identifier can be username, email, or phone_number.""" + + identifier = serializers.CharField() + password = serializers.CharField() + + # --- RequestOTP (request-otp/) --- class RequestOTPSerializer(serializers.Serializer): """Request body for POST /api/auth/request-otp/.""" @@ -26,4 +47,3 @@ class AuthUserSerializer(serializers.Serializer): first_name = serializers.CharField() last_name = serializers.CharField() phone_number = serializers.CharField() - diff --git a/auth/urls.py b/auth/urls.py index aad3e61..08bb538 100644 --- a/auth/urls.py +++ b/auth/urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .views import AuthenticationView +from .views import AuthenticationView, LoginView, RegisterView urlpatterns = [ - path("request-otp/", AuthenticationView.as_view(), name="request-otp"), - path("verify-otp/", AuthenticationView.as_view(), name="verify-otp"), + path("register/", RegisterView.as_view(), name="register"), + path("login/", LoginView.as_view(), name="login"), + # path("request-otp/", AuthenticationView.as_view(), name="request-otp"), + # path("verify-otp/", AuthenticationView.as_view(), name="verify-otp"), ] - diff --git a/auth/views.py b/auth/views.py index af2b583..b6d12a0 100644 --- a/auth/views.py +++ b/auth/views.py @@ -1,15 +1,22 @@ import secrets +from django.contrib.auth import authenticate from django.conf import settings from django.core.cache import cache from django.core.signing import BadSignature, SignatureExpired, TimestampSigner +from django.db import IntegrityError from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken from account.models import User -from .serializers import RequestOTPSerializer, VerifyOTPSerializer +from .serializers import ( + LoginSerializer, + RegisterSerializer, + RequestOTPSerializer, + VerifyOTPSerializer, +) from .sms_service import send_otp_sms @@ -31,6 +38,99 @@ def _auth_user_to_data(user): } +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. + """ + + def post(self, request): + serializer = RegisterSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + try: + user = User.objects.create_user( + username=data["username"], + email=data["email"], + phone_number=data["phone_number"], + password=data["password"], + first_name=data.get("first_name", ""), + last_name=data.get("last_name", ""), + ) + except IntegrityError as exc: + msg = str(exc).lower() + if "username" in msg: + detail = "A user with this username already exists." + elif "email" in msg: + detail = "A user with this email already exists." + elif "phone_number" in msg: + detail = "A user with this phone number already exists." + else: + detail = "A user with these credentials already exists." + return Response( + {"code": 400, "msg": detail}, + status=status.HTTP_400_BAD_REQUEST, + ) + + refresh = RefreshToken.for_user(user) + user_data = _auth_user_to_data(user) + + return Response( + { + "code": 201, + "msg": "success", + "data": user_data, + "token": { + "access": str(refresh.access_token), + "refresh": str(refresh), + }, + }, + status=status.HTTP_201_CREATED, + ) + + +class LoginView(APIView): + """ + POST /api/auth/login/ + Accepts identifier (username, email, or phone_number) + password. + Returns JWT tokens and user data on success. + """ + + def post(self, request): + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + identifier = serializer.validated_data["identifier"] + password = serializer.validated_data["password"] + + user = authenticate(request, username=identifier, password=password) + + if user is None: + return Response( + {"code": 401, "msg": "Invalid credentials."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + refresh = RefreshToken.for_user(user) + user_data = _auth_user_to_data(user) + + return Response( + { + "code": 200, + "msg": "success", + "data": user_data, + "token": { + "access": str(refresh.access_token), + "refresh": str(refresh), + }, + }, + status=status.HTTP_200_OK, + ) + + class AuthenticationView(APIView): """ Single view for auth flows: request-otp and verify-otp. @@ -91,7 +191,10 @@ class AuthenticationView(APIView): user, created = User.objects.get_or_create( phone_number=phone_number, - defaults={"username": phone_number}, + defaults={ + "username": phone_number, + "email": f"{phone_number}@otp.local", + }, ) refresh = RefreshToken.for_user(user) diff --git a/config/settings.py b/config/settings.py index 7030fa6..81c54e1 100644 --- a/config/settings.py +++ b/config/settings.py @@ -13,6 +13,10 @@ ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split("," AUTH_USER_MODEL = "account.User" +AUTHENTICATION_BACKENDS = [ + "account.backends.MultiFieldBackend", +] + INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", @@ -22,7 +26,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "auth.apps.AuthConfig", "account.apps.AccountConfig", - "sensor_hub", + "sensor_hub.apps.SensorHubConfig", "dashboard", "crop_zoning", "plant_simulator", diff --git a/dashboard/mock_data.py b/dashboard/mock_data.py index 4aeb6a9..e4886ed 100644 --- a/dashboard/mock_data.py +++ b/dashboard/mock_data.py @@ -417,10 +417,10 @@ ECONOMIC_OVERVIEW = { # Unified response for GET /api/farm-dashboard (section 5) ALL_CARDS = { - "farmOverviewKpis": FARM_OVERVIEW_KPIS, - "farmWeatherCard": FARM_WEATHER_CARD, - "farmAlertsTracker": FARM_ALERTS_TRACKER, - "sensorValuesList": SENSOR_VALUES_LIST, + "farmOverviewKpis": FARM_OVERVIEW_KPIS , # این باید سه روز یکبار محتواش محاسبه بشه + "farmWeatherCard": FARM_WEATHER_CARD, # هروز + "farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز + "sensorValuesList": SENSOR_VALUES_LIST,#هروز "sensorRadarChart": SENSOR_RADAR_CHART, "sensorComparisonChart": SENSOR_COMPARISON_CHART, "anomalyDetectionCard": ANOMALY_DETECTION_CARD, @@ -430,6 +430,6 @@ ALL_CARDS = { "yieldPredictionChart": YIELD_PREDICTION_CHART, "soilMoistureHeatmap": SOIL_MOISTURE_HEATMAP, "ndviHealthCard": NDVI_HEALTH_CARD, - "recommendationsList": RECOMMENDATIONS_LIST, + "recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه "economicOverview": ECONOMIC_OVERVIEW, } diff --git a/sensor_hub/apps.py b/sensor_hub/apps.py new file mode 100644 index 0000000..4293831 --- /dev/null +++ b/sensor_hub/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SensorHubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "sensor_hub" diff --git a/sensor_hub/migrations/0001_initial.py b/sensor_hub/migrations/0001_initial.py new file mode 100644 index 0000000..181a68c --- /dev/null +++ b/sensor_hub/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.15 on 2026-03-23 18:48 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Sensor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid_sensor', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('specifications', models.JSONField(blank=True, default=dict)), + ('power_source', models.JSONField(blank=True, default=dict)), + ('customized_sensors', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sensors', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'sensors', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/sensor_hub/migrations/__init__.py b/sensor_hub/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_hub/models.py b/sensor_hub/models.py new file mode 100644 index 0000000..ded00ad --- /dev/null +++ b/sensor_hub/models.py @@ -0,0 +1,27 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class Sensor(models.Model): + uuid_sensor = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="sensors", + ) + name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + specifications = models.JSONField(default=dict, blank=True) + power_source = models.JSONField(default=dict, blank=True) + customized_sensors = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "sensors" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.uuid_sensor})" diff --git a/sensor_hub/serializers.py b/sensor_hub/serializers.py index 1e2c58c..6806f91 100644 --- a/sensor_hub/serializers.py +++ b/sensor_hub/serializers.py @@ -1,12 +1,32 @@ from rest_framework import serializers -class SensorStoreResponseSerializer(serializers.Serializer): - """Schema for static sensor store response (name, uuid_sensor, last_updated, specifications, power_source, customized_sensors).""" +from .models import Sensor - name = serializers.CharField() - uuid_sensor = serializers.CharField() - last_updated = serializers.CharField() - specifications = serializers.JSONField() - power_source = serializers.JSONField() - customized_sensors = serializers.JSONField() + +class SensorSerializer(serializers.ModelSerializer): + last_updated = serializers.DateTimeField(source="updated_at", read_only=True) + + class Meta: + model = Sensor + fields = [ + "uuid_sensor", + "name", + "is_active", + "specifications", + "power_source", + "customized_sensors", + "last_updated", + ] + read_only_fields = ["uuid_sensor", "last_updated"] + + +class SensorCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Sensor + fields = [ + "name", + "specifications", + "power_source", + "customized_sensors", + ] diff --git a/sensor_hub/views.py b/sensor_hub/views.py index 12ab728..3320c55 100644 --- a/sensor_hub/views.py +++ b/sensor_hub/views.py @@ -1,97 +1,102 @@ -""" -Sensor Hub module. -All endpoints require authenticated user (must be registered). -All responses are static; no processing or validation on inputs. -""" - from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from .serializers import SensorStoreResponseSerializer - -# Static sensor payload for store (list/get) response. -STORE_DATA = { - "name": "sensor-hub-static", - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", - "last_updated": "2025-02-18T12:00:00Z", - "specifications": { - "model": "SH-1", - "firmware": "1.0.0", - "capabilities": ["temperature", "humidity", "light"], - }, - "power_source": { - "type": "battery", - "voltage": 3.3, - "backup": "solar", - }, - "customized_sensors": { - "thresholds": {"temperature_min": 10, "temperature_max": 35}, - "report_interval_sec": 300, - }, -} - - -# Static payload for single-sensor detail response (same shape as store). -SENSOR_DETAIL_DATA = { - "name": "sensor-hub-static", - "uuid_sensor": "550e8400-e29b-41d4-a716-446655440000", - "last_updated": "2025-02-18T12:00:00Z", - "specifications": { - "model": "SH-1", - "firmware": "1.0.0", - "capabilities": ["temperature", "humidity", "light"], - }, - "power_source": { - "type": "battery", - "voltage": 3.3, - "backup": "solar", - }, - "customized_sensors": { - "thresholds": {"temperature_min": 10, "temperature_max": 35}, - "report_interval_sec": 300, - }, -} +from .models import Sensor +from .serializers import SensorCreateSerializer, SensorSerializer class SensorHubView(APIView): """ - Sensor-hub endpoints. Behavior depends on URL and HTTP method. - No processing or validation is performed on inputs; responses are static. + Sensor-hub CRUD endpoints connected to the database. Routes: - - GET "" → List: returns code 200, msg "success", data with static sensor list. - - GET "/" → Detail: uuid (path). Returns code 200, msg "success", data with static sensor payload. - - POST "" → Add: body/query may be sent but not used. Returns code 200, msg "success". No data field. - - PATCH "/" → Update: uuid (path), body/query may be sent but not used. Returns code 200, msg "success". No data field. - - DELETE "/" → Delete: uuid (path). Returns code 200, msg "success". No data field. - - POST "active/" → Activate: no input. Returns code 200, msg "success". No data field. - - POST "deactive/" → Deactivate: no input. Returns code 200, msg "success". No data field. + - GET "" → List sensors for authenticated user. + - GET "/" → Detail of a single sensor. + - POST "" → Create a new sensor. + - PATCH "/" → Update an existing sensor. + - DELETE "/" → Delete a sensor. + - POST "active/" → Activate a sensor (requires uuid_sensor in body). + - POST "deactive/" → Deactivate a sensor (requires uuid_sensor in body). """ - authentication_classes = [] # No authentication - permission_classes = [] # No permission - # permission_classes = [IsAuthenticated] + + permission_classes = [IsAuthenticated] + + def _get_sensor(self, request, uuid): + try: + return Sensor.objects.get(uuid_sensor=uuid, owner=request.user) + except Sensor.DoesNotExist: + return None def get(self, request, *args, **kwargs): uuid = kwargs.get("uuid") if uuid is not None: - data = SensorStoreResponseSerializer(SENSOR_DETAIL_DATA).data - else: - data = SensorStoreResponseSerializer(STORE_DATA).data + sensor = self._get_sensor(request, uuid) + if sensor is None: + return Response( + {"code": 404, "msg": "Sensor not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + data = SensorSerializer(sensor).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + sensors = Sensor.objects.filter(owner=request.user) + data = SensorSerializer(sensors, many=True).data return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) def post(self, request, *args, **kwargs): action = kwargs.get("action") - if action == "active": - return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) - if action == "deactive": - return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) - # POST without action = add - return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + if action in ("active", "deactive"): + return self._toggle_active(request, is_active=(action == "active")) + + serializer = SensorCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + sensor = serializer.save(owner=request.user) + data = SensorSerializer(sensor).data + return Response( + {"code": 201, "msg": "success", "data": data}, + status=status.HTTP_201_CREATED, + ) def patch(self, request, *args, **kwargs): - return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + uuid = kwargs.get("uuid") + sensor = self._get_sensor(request, uuid) + if sensor is None: + return Response( + {"code": 404, "msg": "Sensor not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = SensorCreateSerializer(sensor, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + data = SensorSerializer(sensor).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) def delete(self, request, *args, **kwargs): + uuid = kwargs.get("uuid") + sensor = self._get_sensor(request, uuid) + if sensor is None: + return Response( + {"code": 404, "msg": "Sensor not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + sensor.delete() + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + def _toggle_active(self, request, is_active): + uuid_sensor = request.data.get("uuid_sensor") + if not uuid_sensor: + return Response( + {"code": 400, "msg": "uuid_sensor is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + sensor = self._get_sensor(request, uuid_sensor) + if sensor is None: + return Response( + {"code": 404, "msg": "Sensor not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + sensor.is_active = is_active + sensor.save(update_fields=["is_active", "updated_at"]) return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)