commit a39d83c241986bcda7a9141f9af6418197597ea7 Author: Mohammad Sajad Pourajam Date: Thu Feb 19 01:19:22 2026 +0330 First commit diff --git a/.cursor/postman.mdc b/.cursor/postman.mdc new file mode 100644 index 0000000..fa8049d --- /dev/null +++ b/.cursor/postman.mdc @@ -0,0 +1,74 @@ +--- +alwaysApply: false +--- +# Backend API Architecture & Postman + +## 1. URL / Routing Architecture + +- **Root (config/urls.py):** API mounts under `api//` via `include()`. + - Example: `path("api/auth/", include("auth.urls"))`, `path("api/sensor-hub/", include("sensor_hub.urls"))`. + - App prefix: kebab-case (e.g. `sensor-hub`). + +- **App URLs (each app’s urls.py):** Only endpoint definitions with `path()`. + - Same view can be used for several paths; distinguish by path or `kwargs` (e.g. `kwargs={"action": "active"}`). + - Order matters: more specific paths first (e.g. `active/`, `deactive/`), then path-param routes (e.g. `/`), then base `""` for list. + - Example pattern: + - `path("active/", View.as_view(), kwargs={"action": "active"})` + - `path("deactive/", View.as_view(), kwargs={"action": "deactive"})` + - `path("/", View.as_view(), name="...-detail")` + - `path("", View.as_view(), name="...-list")` + +- **Views:** One `APIView` per resource (or per flow, e.g. auth). Dispatch by HTTP method and optionally by `request.path` or `kwargs` (e.g. `uuid`, `action`). No business logic in views; orchestration only. + +--- + +## 2. Postman Collection Layout + +- **Placement:** One collection per app: `/postman/.json` (e.g. `sensor_hub/postman/sensor_hub.json`, `auth/postman/postman.json`). + +- **Structure:** + - `info`: `name`, `schema` (v2.1.0), optional `description`. + - `item`: array of requests (one per endpoint variant/method). + - `variable`: at least `baseUrl` (e.g. `http://localhost:8000`); add `token`, `uuid` etc. when needed. + +- **Request style:** + - One base URL per resource; multiple requests for different methods or path params (e.g. list vs `{{uuid}}/`). + - URL: `{{baseUrl}}/api//...` (e.g. `{{baseUrl}}/api/sensor-hub/`, `{{baseUrl}}/api/sensor-hub/{{uuid}}/`). + - Auth: where required, header `Authorization: Bearer {{token}}`. + - No random/dynamic values in body or response examples. + +--- + +## 3. Postman Request Generator (when I give you routes) + +Your task is to take the API routes I provide and convert them into a valid Postman collection JSON (as above). + +ROUTE STYLE: +- When routes are defined as a single URL with different HTTP methods, generate one base URL and multiple requests (one per method/variant). Use the same URL for all; use path params (e.g. `/`) or query for GET detail vs list when applicable. + +RULES: + +1. For each route (or each method/variant on the same URL), generate: + - Name: A descriptive, concise name for the request based on the route and HTTP method. + - Method: The HTTP method (GET, POST, PUT, DELETE, etc.). + - URL: The route URL. + - Body: If the endpoint accepts input, provide a JSON body example with appropriate keys; otherwise, leave it empty. + - Response: Provide a sample JSON response in the following format: + - If the endpoint returns no data: + { + "status": "success" + } + - If the endpoint returns data: + { + "status": "success", + "data": {} + } + - All responses must use HTTP status 200. +2. Do NOT generate random or dynamic values in the body or response. +3. Output must be a valid Postman collection JSON structure: + - Include "info" with collection name. + - Include "item" array with all requests. +4. Keep the JSON fully compatible with Postman import. +5. Do NOT include explanations outside the JSON. + +Wait for me to provide the route definitions. diff --git a/.cursor/project.mdc b/.cursor/project.mdc new file mode 100644 index 0000000..8cc1be1 --- /dev/null +++ b/.cursor/project.mdc @@ -0,0 +1,96 @@ +--- +alwaysApply: true +--- + +## 2. Django App (Module) Naming + +| Item | Convention | Example | +|--------|------------|----------------------------| +| App name | snake_case, **بدون** پسوند `_api` | `account`, `auth`, `sensor_hub` | + +- نام اپ‌ها را با `_api` تمام **نکنید**. مثلاً به‌جای `account_api` از `account` استفاده کنید. +- برای ماژول‌های فقط API، همان نام دامنه کافی است (مثلاً `auth`، `account`). + +--- + +## 3. Model and Database Field Naming + +| Item | Convention | Example | +|-------------------|-------------------------|----------------------------------------------| +| Model | PascalCase | `UserProfile` | +| Fields | snake_case | `first_name`, `email_address` | +| Boolean | `is_` / `has_` + name | `is_active`, `has_paid` | +| Date/Time | `created_at` / `updated_at` | `created_at`, `updated_at` | +| ForeignKey / M2M | snake_case, often model name | `author = ForeignKey(UserProfile)` | +| Choices / Enum | UPPER_SNAKE_CASE values | `role = CharField(choices=(("ADMIN","Admin"), ("USER","User")))` | + +--- + +## 4. DRF Conventions + +- **Serializer:** Validation + data transformation. Use PascalCase names. +- **Service layer:** All business logic lives here. +- **View:** Orchestration only — call services and return responses. No business logic in views. +- **URLs:** Define endpoints only. Use kebab-case for URL paths. + +--- + +## 5. API Response Format + +همه پاسخ‌های API باید فیلد `code` را برگردانند؛ مقدار آن برابر **HTTP status code** درخواست است (مثلاً 200، 201، 400، 404). + +| فیلد | توضیح | +|-------|----------------------------------------| +| `code` | کد وضعیت HTTP (مثلاً 200، 404، 500) | +| `msg` | پیام (مثلاً "success" برای 2xx) | +| `data` | دادهٔ برگشتی (اختیاری) | + +مثال: +```json +{"code": 200, "msg": "success", "data": {...}} +``` + +--- + +## 6. Simple Example: How the Layers Connect (users app) + +```python +# models.py +class UserProfile(models.Model): + first_name = models.CharField(max_length=50) + last_name = models.CharField(max_length=50) + email_address = models.EmailField(unique=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +# serializers.py +class UserCreateSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + fields = ["first_name", "last_name", "email_address"] + + +# services.py +def create_user(first_name, last_name, email_address): + return UserProfile.objects.create( + first_name=first_name, + last_name=last_name, + email_address=email_address, + ) + + +# views.py +class UserCreateAPIView(APIView): + def post(self, request): + serializer = UserCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = create_user(**serializer.validated_data) + return Response({"code": 201, "msg": "success", "data": {"id": user.id}}, status=201) +``` + +- All names follow the conventions above. +- Business logic is in `services.py`. +- Serializer only validates and serializes. +- View only orchestrates (calls service, returns response). diff --git a/.cursor/test-rule.mdc b/.cursor/test-rule.mdc new file mode 100644 index 0000000..6a758c2 --- /dev/null +++ b/.cursor/test-rule.mdc @@ -0,0 +1,72 @@ +--- +alwaysApply: false +--- +You are a Django API code generator. + +Your task is to generate a complete and runnable Django project based on the routes I provide. + +ROUTE STYLE: +- Routes may be defined as a single URL with different HTTP methods (e.g. one path, GET for list, GET with query for detail, PUT/PATCH for update, DELETE for delete, POST for action). Use one view class that implements get, post, put, patch, delete as needed. Use query parameters (e.g. sensor_id) to distinguish list vs detail when both use GET. + +STRICT RULES: + +- Use Django only. +- Do NOT use Django REST Framework unless I explicitly request it. +- Do NOT connect to any database. +- Do NOT create any Models. +- Do NOT generate random or dynamic data. +- Input parameters must be accepted (body, query params, path params). +- HOWEVER, absolutely NO processing, validation, transformation, or logic may be applied to them. +- Do NOT use input values inside the response. +- No conditional logic. +- No business logic. +- No validation. +- All endpoints must always return static JSON responses only. +- ALL responses must return HTTP status code 200 only. +- No other status codes are allowed. +- No explanations outside the code. +- Return complete runnable code including project structure (views.py, urls.py, settings if needed, etc.). + +-------------------------------------------------- +RESPONSE FORMAT (STRICTLY ENFORCED) + +If the endpoint does NOT require returning data: + +{ + "status": "success" +} + +If the endpoint requires returning data: + +{ + "status": "success", + "data": {} +} + +Mandatory rules: +- The "status" field MUST always be exactly "success". +- If "data" is present, it MUST be exactly an empty object {}. +- If data is not required, DO NOT include the "data" field. +- No additional fields are allowed. +-------------------------------------------------- + +COMMENTING REQUIREMENTS (VERY IMPORTANT): + +Each endpoint MUST include professional, multi-line docstring documentation. + +The documentation MUST include: + +1. Clear description of the endpoint purpose. +2. Complete description of ALL input parameters: + - Parameter name + - Data type + - Location (body / query / path) + - Description of its intended purpose +3. Full description of the response structure: + - status field + - data field (if applicable) +4. Explicit statement that no processing or validation is performed on inputs. + +Use clean, professional API documentation style. +Do not write anything outside the code. +Wait for my route definitions. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34fb1e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +.git +__pycache__ +*.pyc +.venv +venv +*.egg-info +.pytest_cache +.coverage +htmlcov +*.log +media +staticfiles +.cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9a0485 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Django +SECRET_KEY=your-secret-key-change-in-production +DEBUG=1 +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database (MySQL) - used by Django in Docker +DB_ENGINE=django.db.backends.mysql +DB_NAME=croplogic +DB_USER=croplogic +DB_PASSWORD=changeme +DB_HOST=db +DB_PORT=3306 + +# Optional: for running manage.py from host (local DB) +# DB_HOST=127.0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f9cd5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Environment +.env +.env.local +*.env +!*.env.example + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Django +*.log +local_settings.py +db.sqlite3 +media/ +staticfiles/ +*.pot + +# Testing / Coverage +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0e05676 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# System deps for MySQL client (pkg-config required by mysqlclient to find libs) +RUN apt-get update && apt-get install -y --no-install-recommends \ + default-libmysqlclient-dev \ + build-essential \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/account/__init__.py b/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/postman/account.json b/account/postman/account.json new file mode 100644 index 0000000..14ed857 --- /dev/null +++ b/account/postman/account.json @@ -0,0 +1,145 @@ +{ + "info": { + "name": "Account", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Account API. GET list, GET by uuid (detail), POST add, PATCH update, DELETE delete, PATCH profile. Authenticated user required." + }, + "item": [ + { + "name": "Update profile", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"email\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/account/profile/", + "description": "Update current user profile (first_name, last_name, email). Returns UpdateProfileResponse." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n }\n}" + } + ] + }, + { + "name": "List accounts", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/account/", + "description": "Get list of accounts. GET on base route." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}" + } + ] + }, + { + "name": "Get account detail (by uuid)", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/account/{{uuid}}/", + "description": "Get one account by uuid in path." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}" + } + ] + }, + { + "name": "Add account", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}" + }, + "url": "{{baseUrl}}/api/account/", + "description": "Add a new account. POST on base route." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}" + } + ] + }, + { + "name": "Update account", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}" + }, + "url": "{{baseUrl}}/api/account/{{uuid}}/", + "description": "Update account by uuid in path. PATCH." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}" + } + ] + }, + { + "name": "Delete account", + "request": { + "method": "DELETE", + "header": [ + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/account/{{uuid}}/", + "description": "Delete account by uuid in path." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}" + } + ] + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""}, + {"key": "uuid", "value": "550e8400-e29b-41d4-a716-446655440000"} + ] +} diff --git a/account/serializers.py b/account/serializers.py new file mode 100644 index 0000000..65f1c53 --- /dev/null +++ b/account/serializers.py @@ -0,0 +1,16 @@ +""" +Account API serializers. +UpdateProfile request/response shapes aligned with frontend types. +""" + +from rest_framework import serializers + + +class UpdateProfileSerializer(serializers.Serializer): + """ + Request body for PATCH /api/account/profile/ (UpdateProfilePayload). + """ + + first_name = serializers.CharField(max_length=150, required=False, allow_blank=True) + last_name = serializers.CharField(max_length=150, required=False, allow_blank=True) + email = serializers.EmailField(required=False, allow_blank=True) diff --git a/account/urls.py b/account/urls.py new file mode 100644 index 0000000..5545e5a --- /dev/null +++ b/account/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AccountView, ProfileView + +urlpatterns = [ + path("profile/", ProfileView.as_view(), name="profile-update"), + path("/", AccountView.as_view(), name="account-detail"), + path("", AccountView.as_view(), name="account-list"), +] diff --git a/account/views.py b/account/views.py new file mode 100644 index 0000000..c0c0fdf --- /dev/null +++ b/account/views.py @@ -0,0 +1,129 @@ +""" +Account API module. +CRUD endpoints for user account profile (first name, last name, phone numbers). +Profile update endpoint returns UpdateProfileResponse (code, msg, data: AuthUser). +""" + +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 UpdateProfileSerializer + + +def _auth_user_to_data(user): + """Build AuthUser-shaped dict from Django User.""" + if user is None or not getattr(user, "pk", None): + return None + # return { + # "id": user.id, + # "username": getattr(user, "username", "") or "", + # "email": getattr(user, "email", "") or "", + # "first_name": getattr(user, "first_name", "") or "", + # "last_name": getattr(user, "last_name", "") or "", + # "phone_number": getattr(user, "phone_number", "") or "", + # } + return { + "id": 1, + "username": "testuser", + "email": "testuser@example.com", + "first_name": "Test", + "last_name": "User", + "phone_number": "09123456789", + } + + +class ProfileView(APIView): + """ + PATCH /api/account/profile/ + UpdateProfilePayload: first_name, last_name, email. + UpdateProfileResponse: code, msg, data (AuthUser). + """ + + permission_classes = [IsAuthenticated] + + def patch(self, request): + serializer = UpdateProfileSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + # TODO: persist first_name, last_name, email via service layer + user = request.user + data = _auth_user_to_data(user) + if data is None: + data = { + "id": 0, + "username": "", + "email": "", + "first_name": "", + "last_name": "", + "phone_number": "", + } + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +class AccountView(APIView): + """ + Account CRUD endpoints. Dispatch by HTTP method and path (uuid for detail/update/delete). + No processing, validation, or transformation is applied to any input. + All endpoints return HTTP 200 only. Response format: {"code": 200, "msg": "success"} or {"code": 200, "msg": "success", "data": {}}. + + Routes: + - GET "" → List: returns status "success", data {}. + - GET "/" → Detail: uuid (path). Returns status "success", data {}. + - POST "" → Create: body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field. + - PATCH "/" → Update: uuid (path), body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field. + - DELETE "/" → Delete: uuid (path). Returns status "success". No data field. + """ + + # permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + """ + List or detail account. + + List (GET on base URL): + - Input parameters: none required. Query params if sent are not processed. + - Response: {"code": 200, "msg": "success", "data": {}}. + - No processing or validation is performed on inputs. + + Detail (GET on /): + - Input parameters: uuid (path, UUID). Description: identifier for the account resource. + - Response: {"code": 200, "msg": "success", "data": {}}. + - No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success", "data": {}}, status=status.HTTP_200_OK) + + def post(self, request, *args, **kwargs): + """ + Create account. + + Input parameters (body, JSON): first_name (string), last_name (string), phones (array of strings). + Description: intended for user first name, last name, and phone numbers. Not processed or validated. + Response: {"code": 200, "msg": "success"}. No data field. + No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + def patch(self, request, *args, **kwargs): + """ + Update account. + + Input parameters: uuid (path, UUID), body (JSON) may contain first_name, last_name, phones. + Description: identifier in path; body fields intended for updated profile. Not processed or validated. + Response: {"code": 200, "msg": "success"}. No data field. + No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + def delete(self, request, *args, **kwargs): + """ + Delete account. + + Input parameters: uuid (path, UUID). Description: identifier for the account resource to delete. + Response: {"code": 200, "msg": "success"}. No data field. + No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/auth/apps.py b/auth/apps.py new file mode 100644 index 0000000..4861118 --- /dev/null +++ b/auth/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auth" + label = "auth_api" # Avoid clash with django.contrib.auth (label "auth") diff --git a/auth/postman/postman.json b/auth/postman/postman.json new file mode 100644 index 0000000..5c9b690 --- /dev/null +++ b/auth/postman/postman.json @@ -0,0 +1,59 @@ +{ + "info": { + "name": "Auth", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Auth API. Request OTP, Verify OTP." + }, + "item": [ + { + "name": "Request OTP", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"phone_number\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/request-otp/", + "description": "Request OTP for the given phone number. In DEBUG mode, response includes debug_note." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"token\": \"\"\n}" + } + ] + }, + { + "name": "Verify OTP", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"token\": \"\",\n \"otp_code\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/verify-otp/", + "description": "Verify OTP with token from request-otp and otp_code sent to user." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n },\n \"token\": \"\"\n}" + } + ] + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""} + ] +} diff --git a/auth/serializers.py b/auth/serializers.py new file mode 100644 index 0000000..8677ed8 --- /dev/null +++ b/auth/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + + +# --- RequestOTP (request-otp/) --- +class RequestOTPSerializer(serializers.Serializer): + """Request body for POST /api/auth/request-otp/.""" + + phone_number = serializers.CharField(max_length=32) + + +# --- VerifyOTP (verify-otp/) --- +class VerifyOTPSerializer(serializers.Serializer): + """Request body for POST /api/auth/verify-otp/.""" + + token = serializers.CharField() + otp_code = serializers.CharField(max_length=10) + + +# --- AuthUser (used in VerifyOTPResponse and UpdateProfileResponse) --- +class AuthUserSerializer(serializers.Serializer): + """User data returned in auth/account responses.""" + + id = serializers.IntegerField() + username = serializers.CharField() + email = serializers.EmailField(allow_blank=True) + first_name = serializers.CharField() + last_name = serializers.CharField() + phone_number = serializers.CharField() + diff --git a/auth/urls.py b/auth/urls.py new file mode 100644 index 0000000..aad3e61 --- /dev/null +++ b/auth/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AuthenticationView + +urlpatterns = [ + 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 new file mode 100644 index 0000000..65728f7 --- /dev/null +++ b/auth/views.py @@ -0,0 +1,93 @@ +import secrets + +from django.conf import settings +from django.core.cache import cache +from django.core.signing import TimestampSigner +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .serializers import RequestOTPSerializer, VerifyOTPSerializer + + +OTP_TTL_SECONDS = 300 +OTP_SIGNER = TimestampSigner(salt="auth.otp") + + +def _auth_user_to_data(user): + """Build AuthUser-shaped dict from Django User (or mock).""" + # if user is None or not getattr(user, "pk", None): + # return None + # return { + # "id": user.id, + # "username": getattr(user, "username", "") or "", + # "email": getattr(user, "email", "") or "", + # "first_name": getattr(user, "first_name", "") or "", + # "last_name": getattr(user, "last_name", "") or "", + # "phone_number": getattr(user, "phone_number", "") or "", + # } + return { + "id": 1, + "username": "testuser", + "email": "testuser@example.com", + "first_name": "Test", + "last_name": "User", + "phone_number": "09123456789", + } + + +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). + """ + + def post(self, request): + if "verify-otp" in request.path: + return self._verify_otp(request) + return self._request_otp(request) + + def _request_otp(self, request): + serializer = RequestOTPSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + phone_number = serializer.validated_data["phone_number"].strip() + otp_code = f"{secrets.randbelow(1_000_000):06d}" + + cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS) + otp_token = OTP_SIGNER.sign(phone_number) + + payload = {"code": 200, "msg": "success", "token": otp_token} + if settings.DEBUG: + payload["debug_note"] = "OTP code is returned only when DEBUG=1." + + return Response(payload, status=status.HTTP_200_OK) + + def _verify_otp(self, request): + serializer = VerifyOTPSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # TODO: validate token + otp_code, load or create user, issue JWT/session token + auth_token = "1234567890" + user_data = _auth_user_to_data(getattr(request, "user", None)) + if user_data is None: + user_data = { + "id": 0, + "username": "", + "email": "", + "first_name": "", + "last_name": "", + "phone_number": "", + } + + return Response( + { + "code": 200, + "msg": "success", + "data": user_data, + "token": auth_token, + }, + status=status.HTTP_200_OK, + ) + diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..b80dd36 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,105 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") +DEBUG = os.environ.get("DEBUG", "0") == "1" +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "auth.apps.AuthConfig", + "account", + "sensor_hub", + "rest_framework", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" +WSGI_APPLICATION = "config.wsgi.application" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +DATABASES = { + "default": { + "ENGINE": os.environ.get("DB_ENGINE", "django.db.backends.mysql"), + "NAME": os.environ.get("DB_NAME", "croplogic"), + "USER": os.environ.get("DB_USER", "croplogic"), + "PASSWORD": os.environ.get("DB_PASSWORD", ""), + "HOST": os.environ.get("DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("DB_PORT", "3306"), + "OPTIONS": { + "charset": "utf8mb4", + }, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "croplogic-auth-otp", + } +} + +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + ], +} + +CORS_ALLOW_ALL_ORIGINS = DEBUG diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..3a55ec4 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/auth/", include("auth.urls")), + path("api/account/", include("account.urls")), + path("api/sensor-hub/", include("sensor_hub.urls")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml new file mode 100644 index 0000000..ac3a959 --- /dev/null +++ b/docker-compose-prod.yaml @@ -0,0 +1,46 @@ +# Production: no source mount; image contains code +services: + db: + image: mysql:8.0 + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + phpmyadmin: + image: phpmyadmin:latest + environment: + PMA_HOST: db + PMA_PORT: 3306 + UPLOAD_LIMIT: 64M + ports: + - "8080:80" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + web: + build: . + env_file: + - .env + environment: + DB_HOST: db + depends_on: + db: + condition: service_healthy + restart: unless-stopped + ports: + - "8000:8000" + +volumes: + mysql_data: diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..42cc3c3 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,46 @@ +# Development: volumes mount source so code updates apply without rebuild +services: + db: + image: mysql:8.0 + environment: + MYSQL_DATABASE: ${DB_NAME:-croplogic} + MYSQL_USER: ${DB_USER:-croplogic} + MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme} + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"] + interval: 5s + timeout: 5s + retries: 5 + + phpmyadmin: + image: phpmyadmin:latest + environment: + PMA_HOST: db + PMA_PORT: 3306 + UPLOAD_LIMIT: 64M + ports: + - "8080:80" + depends_on: + db: + condition: service_healthy + + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/app + ports: + - "8000:8000" + env_file: + - .env + environment: + DB_HOST: db + depends_on: + db: + condition: service_healthy + +volumes: + mysql_data: diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..d28672e --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36cb173 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Django>=5.0,<6 +djangorestframework>=3.14,<4 +djangorestframework-simplejwt>=5.3,<6 +django-cors-headers>=4.3,<5 +mysqlclient>=2.2,<3 +gunicorn>=22,<25 +python-dotenv>=1.0,<2 diff --git a/sensor_hub/__init__.py b/sensor_hub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_hub/postman/sensor_hub.json b/sensor_hub/postman/sensor_hub.json new file mode 100644 index 0000000..f4897f7 --- /dev/null +++ b/sensor_hub/postman/sensor_hub.json @@ -0,0 +1,132 @@ +{ + "info": { + "name": "Sensor Hub", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Sensor Hub API. GET list, GET by uuid (detail), POST add, PATCH update, DELETE delete, POST active/deactive. Authenticated user required. Static responses only." + }, + "item": [ + { + "name": "List sensors", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/sensor-hub/", + "description": "Get list of sensors. GET on base route." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"name\": \"sensor-hub-static\",\n \"uuid_sensor\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"last_updated\": \"2025-02-18T12:00:00Z\",\n \"specifications\": {\n \"model\": \"SH-1\",\n \"firmware\": \"1.0.0\",\n \"capabilities\": [\"temperature\", \"humidity\", \"light\"]\n },\n \"power_source\": {\n \"type\": \"battery\",\n \"voltage\": 3.3,\n \"backup\": \"solar\"\n },\n \"customized_sensors\": {\n \"thresholds\": {\"temperature_min\": 10, \"temperature_max\": 35},\n \"report_interval_sec\": 300\n }\n }\n}" + } + ] + }, + { + "name": "Get sensor details (by uuid)", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/sensor-hub/{{uuid}}/", + "description": "Get one sensor by uuid in path." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"name\": \"sensor-hub-static\",\n \"uuid_sensor\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"last_updated\": \"2025-02-18T12:00:00Z\",\n \"specifications\": {\n \"model\": \"SH-1\",\n \"firmware\": \"1.0.0\",\n \"capabilities\": [\"temperature\", \"humidity\", \"light\"]\n },\n \"power_source\": {\n \"type\": \"battery\",\n \"voltage\": 3.3,\n \"backup\": \"solar\"\n },\n \"customized_sensors\": {\n \"thresholds\": {\"temperature_min\": 10, \"temperature_max\": 35},\n \"report_interval_sec\": 300\n }\n }\n}" + } + ] + }, + { + "name": "Add sensor", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{}"}, + "url": "{{baseUrl}}/api/sensor-hub/", + "description": "Add a new sensor. POST on base route." + }, + "response": [ + {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} + ] + }, + { + "name": "Update sensor", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{}"}, + "url": "{{baseUrl}}/api/sensor-hub/{{uuid}}/", + "description": "Update sensor by uuid in path. PATCH." + }, + "response": [ + {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} + ] + }, + { + "name": "Delete sensor", + "request": { + "method": "DELETE", + "header": [ + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/sensor-hub/{{uuid}}/", + "description": "Delete sensor by uuid in path." + }, + "response": [ + {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} + ] + }, + { + "name": "Activate", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{}"}, + "url": "{{baseUrl}}/api/sensor-hub/active/", + "description": "Activate. POST on active/ route." + }, + "response": [ + {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} + ] + }, + { + "name": "Deactivate", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{}"}, + "url": "{{baseUrl}}/api/sensor-hub/deactive/", + "description": "Deactivate. POST on deactive/ route." + }, + "response": [ + {"name": "Success", "status": "OK", "code": 200, "body": "{\n \"status\": \"success\"\n}"} + ] + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""}, + {"key": "uuid", "value": "550e8400-e29b-41d4-a716-446655440000"} + ] +} diff --git a/sensor_hub/serializers.py b/sensor_hub/serializers.py new file mode 100644 index 0000000..1e2c58c --- /dev/null +++ b/sensor_hub/serializers.py @@ -0,0 +1,12 @@ +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).""" + + name = serializers.CharField() + uuid_sensor = serializers.CharField() + last_updated = serializers.CharField() + specifications = serializers.JSONField() + power_source = serializers.JSONField() + customized_sensors = serializers.JSONField() diff --git a/sensor_hub/urls.py b/sensor_hub/urls.py new file mode 100644 index 0000000..9155a54 --- /dev/null +++ b/sensor_hub/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import SensorHubView + +urlpatterns = [ + path("active/", SensorHubView.as_view(), name="sensor-hub-active", kwargs={"action": "active"}), + path("deactive/", SensorHubView.as_view(), name="sensor-hub-deactive", kwargs={"action": "deactive"}), + path("/", SensorHubView.as_view(), name="sensor-hub-detail"), + path("", SensorHubView.as_view(), name="sensor-hub-list"), +] diff --git a/sensor_hub/views.py b/sensor_hub/views.py new file mode 100644 index 0000000..d251784 --- /dev/null +++ b/sensor_hub/views.py @@ -0,0 +1,96 @@ +""" +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, + }, +} + + +class SensorHubView(APIView): + """ + Sensor-hub endpoints. Behavior depends on URL and HTTP method. + No processing or validation is performed on inputs; responses are static. + + 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. + """ + + # permission_classes = [IsAuthenticated] + + 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 + 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) + + def patch(self, request, *args, **kwargs): + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + def delete(self, request, *args, **kwargs): + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)