First commit

This commit is contained in:
2026-02-19 01:19:22 +03:30
commit a39d83c241
32 changed files with 1350 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
---
alwaysApply: false
---
# Backend API Architecture & Postman
## 1. URL / Routing Architecture
- **Root (config/urls.py):** API mounts under `api/<app-prefix>/` 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 apps 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. `<uuid:uuid>/`), then base `""` for list.
- Example pattern:
- `path("active/", View.as_view(), kwargs={"action": "active"})`
- `path("deactive/", View.as_view(), kwargs={"action": "deactive"})`
- `path("<uuid:uuid>/", 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: `<app_name>/postman/<collection_name>.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/<app-prefix>/...` (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. `<uuid>/`) 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.
+96
View File
@@ -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).
+72
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
.env
.env.*
!.env.example
.git
__pycache__
*.pyc
.venv
venv
*.egg-info
.pytest_cache
.coverage
htmlcov
*.log
media
staticfiles
.cursor
+15
View File
@@ -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
+59
View File
@@ -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
+22
View File
@@ -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"]
View File
+145
View File
@@ -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"}
]
}
+16
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import AccountView, ProfileView
urlpatterns = [
path("profile/", ProfileView.as_view(), name="profile-update"),
path("<uuid:uuid>/", AccountView.as_view(), name="account-detail"),
path("", AccountView.as_view(), name="account-list"),
]
+129
View File
@@ -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 "<uuid>/" → 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 "<uuid>/" → Update: uuid (path), body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field.
- DELETE "<uuid>/" → 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 <uuid>/):
- 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)
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -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")
+59
View File
@@ -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": ""}
]
}
+29
View File
@@ -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()
+9
View File
@@ -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"),
]
+93
View File
@@ -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,
)
View File
+7
View File
@@ -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()
+105
View File
@@ -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
+9
View File
@@ -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")),
]
+7
View File
@@ -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()
+46
View File
@@ -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:
+46
View File
@@ -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:
+22
View File
@@ -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()
+7
View File
@@ -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
View File
+132
View File
@@ -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"}
]
}
+12
View File
@@ -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()
+10
View File
@@ -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("<uuid:uuid>/", SensorHubView.as_view(), name="sensor-hub-detail"),
path("", SensorHubView.as_view(), name="sensor-hub-list"),
]
+96
View File
@@ -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 "<uuid>/" 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 "<uuid>/" Update: uuid (path), body/query may be sent but not used. Returns code 200, msg "success". No data field.
- DELETE "<uuid>/" 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)